Compare commits
63 Commits
4649289213
...
fix/rep-au
| Author | SHA1 | Date | |
|---|---|---|---|
| 996eff8e65 | |||
| f543b8ac7f | |||
| 518769218f | |||
| 2a33a5aa4b | |||
| 8838db16ae | |||
| a614147fc4 | |||
| 26857643fb | |||
| 51602dd47e | |||
| 2649bc9e94 | |||
| 264305386d | |||
| bf6e21ce47 | |||
| 32ff4b43fd | |||
| 795b805ae5 | |||
| a6cb1df2aa | |||
| 5bda00009f | |||
| 289c1a071e | |||
| 400fcb3360 | |||
| a2bab75bad | |||
| 0858e1e941 | |||
| fc6cc4c534 | |||
| 3413fa7003 | |||
| 34368e8006 | |||
| 236dd69eba | |||
| ec0b7c1611 | |||
| 35d0ba68d6 | |||
| 6f16dc8274 | |||
| 6fbf8bfb8e | |||
| a3c68f9f05 | |||
| 1647871a39 | |||
| 70d5a2d1e4 | |||
| 2d4f342697 | |||
| fb6df551b7 | |||
| 7fad03475e | |||
| a00a5c6a53 | |||
| 20b0793227 | |||
| f9d5f8a84c | |||
| e7cbadcf7e | |||
| 1f8a9d872a | |||
| 24408ecd83 | |||
| 2abe5e8697 | |||
| f41d9c2f16 | |||
| 6cdb4c578e | |||
| f363d22d90 | |||
| b0b60d7a14 | |||
| 246eb28bb1 | |||
| a1a852c44d | |||
| e3587e680a | |||
| da2f1020d1 | |||
| 56ca650962 | |||
| acab5b8a55 | |||
| a9c15ac4ec | |||
| 93bf906eec | |||
| 36103eaa87 | |||
| 6028bf1ba9 | |||
| 356c8e3c2c | |||
| 6769a0d82a | |||
| c36451dd33 | |||
| 14c8350216 | |||
| 2a8be3fd82 | |||
| bca2e3ebb3 | |||
| 70ecfdc927 | |||
| fdbf40cd1a | |||
| 29321f54c0 |
@@ -45,6 +45,14 @@ OTEL_TRACES_SAMPLER=parentbased_traceidratio
|
|||||||
OTEL_TRACES_SAMPLER_ARG=1.0
|
OTEL_TRACES_SAMPLER_ARG=1.0
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
# Web Push VAPID (C6) — gerar com: node -e "const wp=require('web-push'); const k=wp.generateVAPIDKeys(); console.log(k)"
|
||||||
|
# Em prod: Vault injeta. Em dev: opcional — push fica desabilitado se vazio.
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_CONTACT=mailto:noreply@sar.dev
|
||||||
|
# Chave pública VAPID para o front-end (mesmo valor de VAPID_PUBLIC_KEY)
|
||||||
|
VITE_VAPID_PUBLIC_KEY=
|
||||||
|
|
||||||
# Feature flags (DEV: bypass. Prod: GrowthBook self-host)
|
# Feature flags (DEV: bypass. Prod: GrowthBook self-host)
|
||||||
GROWTHBOOK_API_HOST=http://localhost:3100
|
GROWTHBOOK_API_HOST=http://localhost:3100
|
||||||
GROWTHBOOK_CLIENT_KEY=
|
GROWTHBOOK_CLIENT_KEY=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
out
|
out
|
||||||
|
tmp
|
||||||
.nx
|
.nx
|
||||||
.next
|
.next
|
||||||
.turbo
|
.turbo
|
||||||
|
|||||||
32
.gitleaks.toml
Normal file
32
.gitleaks.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Gitleaks — SAR Força de Vendas
|
||||||
|
# Documentação: https://github.com/gitleaks/gitleaks
|
||||||
|
|
||||||
|
title = "SAR gitleaks config"
|
||||||
|
|
||||||
|
[extend]
|
||||||
|
useDefault = true # herda todas as regras padrão
|
||||||
|
|
||||||
|
[allowlist]
|
||||||
|
description = "Arquivos e padrões seguros conhecidos"
|
||||||
|
|
||||||
|
paths = [
|
||||||
|
# Arquivos de exemplo — contêm placeholders, nunca segredos reais
|
||||||
|
".env.example",
|
||||||
|
".env.test",
|
||||||
|
# Lock files gerados pelo pnpm — nunca contêm segredos
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
# Ferramentas de agente (BMad skills, Claude config) — docs/templates, não código de produto
|
||||||
|
'''.agents/''',
|
||||||
|
'''.claude/''',
|
||||||
|
# Arquivos temporários / relatórios de CI gerados localmente
|
||||||
|
'''tmp/''',
|
||||||
|
]
|
||||||
|
|
||||||
|
regexes = [
|
||||||
|
# Hashes de commit no design log e docs
|
||||||
|
'''[0-9a-f]{7,40}''',
|
||||||
|
# UUIDs canônicos usados em testes (requestId, workspaceId)
|
||||||
|
'''[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}''',
|
||||||
|
# Valores placeholder explícitos em .env.example
|
||||||
|
'''(your-|change-me|placeholder|CHANGE_ME|YOUR_)''',
|
||||||
|
]
|
||||||
2
.husky/commit-msg
Executable file
2
.husky/commit-msg
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
pnpm exec commitlint --edit "$1"
|
||||||
21
.husky/pre-commit
Executable file
21
.husky/pre-commit
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# SAR pre-commit: lint-staged → gitleaks
|
||||||
|
|
||||||
|
pnpm exec lint-staged
|
||||||
|
|
||||||
|
# Gitleaks — detecta segredos antes de empurrar pro Gitea.
|
||||||
|
# Roda via Docker para não exigir instalação local.
|
||||||
|
# Fallback silencioso se Docker não estiver disponível (CI tem o binário nativo).
|
||||||
|
if command -v docker > /dev/null 2>&1 && docker info > /dev/null 2>&1; then
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(pwd)":/path \
|
||||||
|
-w /path \
|
||||||
|
zricethezav/gitleaks:latest detect \
|
||||||
|
--config .gitleaks.toml \
|
||||||
|
--source . \
|
||||||
|
--no-git \
|
||||||
|
--redact \
|
||||||
|
--exit-code 1
|
||||||
|
else
|
||||||
|
echo "[pre-commit] Docker indisponível — gitleaks pulado (rode manualmente antes de push)"
|
||||||
|
fi
|
||||||
1
.npmrc
1
.npmrc
@@ -6,4 +6,5 @@ shamefully-hoist=false
|
|||||||
public-hoist-pattern[]=*types*
|
public-hoist-pattern[]=*types*
|
||||||
public-hoist-pattern[]=*eslint*
|
public-hoist-pattern[]=*eslint*
|
||||||
public-hoist-pattern[]=*prettier*
|
public-hoist-pattern[]=*prettier*
|
||||||
|
public-hoist-pattern[]=@prisma/client-runtime-utils
|
||||||
node-linker=isolated
|
node-linker=isolated
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Investigation: Pedidos — Valores incorretos e vinculação clientes × vendedor
|
||||||
|
|
||||||
|
## Hand-off Brief
|
||||||
|
|
||||||
|
1. **O que aconteceu.** Pedidos exibem valores/status errados e a vinculação cliente↔vendedor está inconsistente; suspeita confirmada de 5 bugs distintos, sendo o mais grave o mismatch de códigos `situa` entre ERP e SAR.
|
||||||
|
2. **Onde o caso está.** Cinco problemas confirmados ou fortemente deduzidos; `vw_pedidos_erp` (view central usada pelo service) **não está definida em nenhum arquivo de código** — sua definição existe apenas no banco.
|
||||||
|
3. **O que é necessário agora.** Verificar a DDL de `vw_pedidos_erp` diretamente no banco (`\d+ sarweb.vw_pedidos_erp`) e confirmar se ela normaliza `situa` — isso resolve ou agrava o Bug #1.
|
||||||
|
|
||||||
|
## Case Info
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
| ---------------- | ------------------------------------------------------------------ |
|
||||||
|
| Ticket | N/A |
|
||||||
|
| Data | 2026-05-30 |
|
||||||
|
| Status | Active |
|
||||||
|
| Sistema | Node 24 / NestJS 11 / Prisma 7 / PostgreSQL (SIG+GERENTE schemas) |
|
||||||
|
| Fontes | orders.service.ts, clients.service.ts, sarweb_views.sql, schema.prisma, .env, jwt-auth.guard.ts |
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Usuário relata: "pedidos com valores errados e vinculação clientes × vendedor"; complementou que a empresa usa `id_empresa = 9001` (schema SIG) e a empresa gerencial é a `1` (schema GERENTE), e que às vezes dados estão duplicando nas telas.
|
||||||
|
|
||||||
|
## Evidence Inventory
|
||||||
|
|
||||||
|
| Fonte | Status | Notas |
|
||||||
|
| --------------------------------------- | ---------- | -------------------------------------------------------- |
|
||||||
|
| `orders.service.ts` | Available | Lido completo — queries brutas sem esquema qualificado |
|
||||||
|
| `clients.service.ts` | Available | Lido completo |
|
||||||
|
| `sarweb_views.sql` | Available | Define `vw_pedidos`, NÃO define `vw_pedidos_erp` |
|
||||||
|
| `schema.prisma` | Available | Schema `sar` no PG; tabela `pedidos` é SAR-only |
|
||||||
|
| `jwt-auth.guard.ts` | Available | Em dev usa `DEV_EMPRESA_ID` e `DEV_REP_CODE` do `.env` |
|
||||||
|
| `.env` | Available | `DEV_EMPRESA_ID=1`, `DEV_REP_CODE=29` |
|
||||||
|
| DDL de `vw_pedidos_erp` no banco | **Missing** | Não está no repo — requer `\d+` direto no PG |
|
||||||
|
| Logs do PG / EXPLAIN com id_empresa=9001| **Missing** | Requer execução manual |
|
||||||
|
|
||||||
|
## Confirmed Findings
|
||||||
|
|
||||||
|
### Finding 1: `DEV_EMPRESA_ID=1` mas a empresa real é SIG (id=9001)
|
||||||
|
|
||||||
|
**Evidência:** `.env:DEV_EMPRESA_ID=1` e `jwt-auth.guard.ts:56` — em dev, `idEmpresa` é forçado para `1` (GERENTE), ignorando o JWT.
|
||||||
|
|
||||||
|
**Detalhe:** Em desenvolvimento, todas as queries usam `WHERE id_empresa = 1` e buscam dados do schema GERENTE. A empresa real do usuário está no schema SIG com `id_empresa = 9001`. Portanto, os testes locais estão apontando para dados diferentes dos de produção.
|
||||||
|
|
||||||
|
**Fix imediato:** Alterar `.env`: `DEV_EMPRESA_ID=9001`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Finding 2: `vw_pedidos_erp` não existe em nenhum arquivo do repositório
|
||||||
|
|
||||||
|
**Evidência:**
|
||||||
|
- `orders.service.ts:77` — `FROM vw_pedidos_erp e`
|
||||||
|
- `sarweb_views.sql` — define `sarweb.vw_pedidos`, não `vw_pedidos_erp`
|
||||||
|
- `grep -rn "vw_pedidos_erp"` → apenas `orders.service.ts` e `dashboard.service.ts`; zero arquivos SQL
|
||||||
|
|
||||||
|
**Detalhe:** A view existe somente no banco (criada manualmente). Seu comportamento exato — especialmente se normaliza `situa` do ERP para valores SAR — é desconhecido sem inspecionar o banco.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Finding 3: Mismatch de códigos `situa` entre ERP e SAR
|
||||||
|
|
||||||
|
**Evidência:** `sarweb_views.sql:367-378` (GERENTE) vs `sarweb_views.sql:411-418` (SIG) vs `order.contract.ts:13-18` (SAR).
|
||||||
|
|
||||||
|
| Situa | GERENTE ERP | SIG ERP | SAR (app) |
|
||||||
|
|-------|-------------|-------------|-----------------|
|
||||||
|
| 1 | Pendente | Pendente | Ag. Aprovação |
|
||||||
|
| 2 | Liberado | Liberado | Aprovado |
|
||||||
|
| 3 | **Faturado**| Liberado | **Cancelado** |
|
||||||
|
| 4 | **Cancelado**| **Faturado**| **Faturado** |
|
||||||
|
| 5 | — | **Cancelado**| — |
|
||||||
|
|
||||||
|
**Efeito confirmado (GERENTE):** Um pedido FATURADO no ERP (`situa=3`) exibe cor **vermelha** (Cancelado) no SAR — porque `OrderStatusBadge` usa o número cru para definir `tagColor`. O texto pode estar correto (via `statusDescr`) mas a cor é errada.
|
||||||
|
|
||||||
|
**Efeito no filtro de status:** `situaFilter = situa != null ? 'AND e.situa = ${situa}' : ''` — se o usuário filtra `situa=3` (SAR=Cancelado), recebe pedidos FATURADOS do GERENTE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Finding 4: `dt_ultima_compra` ignorando histórico ERP — `activityStatus` sempre errado
|
||||||
|
|
||||||
|
**Evidência:** `clients.service.ts:100` —
|
||||||
|
```sql
|
||||||
|
LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente
|
||||||
|
AND p.id_empresa = c.id_empresa
|
||||||
|
AND p.situa != 3
|
||||||
|
```
|
||||||
|
`pedidos` aqui é a tabela SAR (`sar.pedidos`, Prisma), não os pedidos históricos do ERP.
|
||||||
|
|
||||||
|
**Detalhe:** Clientes que têm anos de histórico no ERP mas nunca fizeram pedido pelo SAR ficam com `dt_ultima_compra = NULL` → `activityStatus = 'inactive'`. Portanto, a carteira inteira aparece como **"Inativo"** na tela de Clientes.
|
||||||
|
|
||||||
|
**Fix necessário:** O JOIN deve usar `vw_pedidos_erp` (ou equivalente) em vez da tabela SAR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Finding 5: Filtro por `status` de clientes aplicado DEPOIS da paginação SQL
|
||||||
|
|
||||||
|
**Evidência:** `clients.service.ts:138` —
|
||||||
|
```typescript
|
||||||
|
if (status) mapped = mapped.filter((c) => c.activityStatus === status);
|
||||||
|
```
|
||||||
|
O `total` vem de `SELECT COUNT(*)` sem o filtro de status (`clients.service.ts:114-122`).
|
||||||
|
|
||||||
|
**Efeito:** Ao filtrar por `status=active`, a API retorna menos itens que `limit` (ex.: 8 de 50), mas `total` ainda diz 2606. A paginação fica quebrada e a portfólio card mostra totais incorretos quando usa `useClientList({ limit:1, status:X })`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hypothesized Paths
|
||||||
|
|
||||||
|
### Hypothesis 1: `vw_pedidos_erp` causa duplicação via UNION sem filtro por empresa
|
||||||
|
|
||||||
|
**Status:** Open
|
||||||
|
|
||||||
|
**Teoria:** `vw_pedidos_erp` faz UNION ALL de GERENTE + SIG sem filtrar por `id_empresa`, e para uma empresa que existe nos dois schemas (id=1 e id=9001), as mesmas ordens aparecem duas vezes.
|
||||||
|
|
||||||
|
**Confirmaria:** `\d+ sarweb.vw_pedidos_erp` mostrando UNION ALL sem cláusula WHERE por empresa, + query retornando duplicatas com `id_empresa` distintos.
|
||||||
|
|
||||||
|
**Refutaria:** View com UNION ALL onde cada SELECT tem `AND id_empresa = X` fixo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Hypothesis 2: SAR-created orders nunca aparecem na listagem
|
||||||
|
|
||||||
|
**Status:** Open
|
||||||
|
|
||||||
|
**Teoria:** `orders.service.ts list()` só consulta `vw_pedidos_erp` (ERP), nunca `sar.pedidos` (Prisma). Pedidos criados pelo SAR somem da lista após criação.
|
||||||
|
|
||||||
|
**Confirmaria:** Criar pedido via SAR → abrir `/pedidos` → pedido não aparece na lista.
|
||||||
|
|
||||||
|
**Refutaria:** `vw_pedidos_erp` inclui `sar.pedidos` via UNION ALL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Evidence
|
||||||
|
|
||||||
|
| Gap | Impacto | Como obter |
|
||||||
|
| -------------------------------- | -------------------------------------------- | --------------------------------------------- |
|
||||||
|
| DDL de `vw_pedidos_erp` | Confirma/refuta H1, H2 e Bug #3 (situa) | `\d+ sarweb.vw_pedidos_erp` no psql |
|
||||||
|
| Query real com id_empresa=9001 | Confirma duplicação e valores | Rodar GET /orders com JWT prod ou DEV_ID=9001 |
|
||||||
|
| Confirmar se `sar.pedidos` aparece na lista | Confirma H2 | Criar pedido SAR → verificar lista /orders |
|
||||||
|
|
||||||
|
## Source Code Trace
|
||||||
|
|
||||||
|
| Elemento | Detalhe |
|
||||||
|
| -------------- | ------------------------------------------------------------- |
|
||||||
|
| Bug #1 origem | `jwt-auth.guard.ts:56` — `DEV_EMPRESA_ID` sobrescreve JWT |
|
||||||
|
| Bug #2 origem | Banco de dados (DDL não versionada) |
|
||||||
|
| Bug #3 origem | `orders.service.ts:43,45,98-99` — `situa` cru do ERP |
|
||||||
|
| Bug #4 origem | `clients.service.ts:100` — JOIN com `sar.pedidos` (não ERP) |
|
||||||
|
| Bug #5 origem | `clients.service.ts:138` — filter JS pós-paginação SQL |
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**Confidence:** Medium (root causes identificadas; DDL de `vw_pedidos_erp` é peça faltante)
|
||||||
|
|
||||||
|
Cinco bugs confirmados ou fortemente deduzidos explicam os sintomas:
|
||||||
|
|
||||||
|
1. **DEV aponta para empresa errada** (`DEV_EMPRESA_ID=1` vs real=9001) — dados diferentes entre dev e prod.
|
||||||
|
2. **`situa` ERP ≠ SAR** — cores/filtros de status errados para pedidos históricos.
|
||||||
|
3. **`dt_ultima_compra` ignora ERP** — carteira toda aparece inativa.
|
||||||
|
4. **`status` filter pós-paginação** — totais e paginação quebrados.
|
||||||
|
5. **`vw_pedidos_erp` não versionada** — comportamento opaco, possível fonte de duplicação.
|
||||||
|
|
||||||
|
## Recommended Next Steps
|
||||||
|
|
||||||
|
### Fix imediato (sem risco)
|
||||||
|
Alterar `.env`: `DEV_EMPRESA_ID=9001` para que dev espelhe produção.
|
||||||
|
|
||||||
|
### Antes de qualquer outro fix: verificar `vw_pedidos_erp` no banco
|
||||||
|
```sql
|
||||||
|
-- Rodar diretamente no psql:
|
||||||
|
\d+ sarweb.vw_pedidos_erp
|
||||||
|
-- ou
|
||||||
|
SELECT pg_get_viewdef('sarweb.vw_pedidos_erp', true);
|
||||||
|
```
|
||||||
|
O resultado define o caminho dos próximos fixes.
|
||||||
|
|
||||||
|
### Fix #3 — `situa` — normalizar na `vw_pedidos_erp` (ou no service)
|
||||||
|
Adicionar CASE na view (ou no service) mapeando ERP situa → SAR situa:
|
||||||
|
```sql
|
||||||
|
-- GERENTE: 2→2, 3→4(Faturado), 4→3(Cancelado)
|
||||||
|
-- SIG: 2→2, 4→4(Faturado), 5→3(Cancelado)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix #4 — `dt_ultima_compra` — usar ERP orders
|
||||||
|
Em `clients.service.ts:100`, substituir `pedidos` por `vw_pedidos_erp` (ou a view equivalente):
|
||||||
|
```sql
|
||||||
|
LEFT JOIN vw_pedidos_erp p
|
||||||
|
ON p.id_cliente = c.id_cliente
|
||||||
|
AND p.id_empresa = c.id_empresa
|
||||||
|
AND p.situa NOT IN (3, 4, 5) -- situa=cancelado nos dois sistemas
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix #5 — `status` filter — mover para SQL
|
||||||
|
Em `clients.service.ts`, incluir o filtro de `activityStatus` na query SQL via subquery ou CTE com `dt_ultima_compra`, eliminando o filter JS pós-paginação.
|
||||||
|
|
||||||
|
## Reproduction Plan
|
||||||
|
|
||||||
|
1. Alterar `DEV_EMPRESA_ID=9001` no `.env`
|
||||||
|
2. Reiniciar a API
|
||||||
|
3. Abrir `/clientes` → verificar se clientes aparecem e se `activityStatus` faz sentido
|
||||||
|
4. Abrir `/pedidos` → verificar se pedidos aparecem e se status está correto
|
||||||
|
5. Rodar `\d+ sarweb.vw_pedidos_erp` no banco e trazer o resultado para continuar a investigação
|
||||||
|
|
||||||
|
## Side Findings
|
||||||
|
|
||||||
|
- `ALERT_DAYS=30` e `INACTIVE_DAYS=60` estão hardcoded em `clients.service.ts:11-12`. O comentário diz "Configuráveis por empresa futuramente" — tarefa pendente.
|
||||||
|
- `orders.service.ts:46` usa interpolação de string direta para `numPedSar` (ILIKE). O `escSql()` de `clients.service.ts:24` não é reutilizado aqui — potencial SQL injection menor para campo de busca.
|
||||||
|
- `vw_pedidos` em `sarweb_views.sql` não é usada em lugar nenhum do código fonte — `vw_pedidos_erp` é usada em seu lugar. Possível que `vw_pedidos_erp` seja um rename ou extensão da `vw_pedidos`.
|
||||||
348
_bmad-output/planning-artifacts/architecture.md
Normal file
348
_bmad-output/planning-artifacts/architecture.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
---
|
||||||
|
stepsCompleted: [1, 2, 3, 4, 5]
|
||||||
|
inputDocuments:
|
||||||
|
- design-artifacts/A-Product-Brief/01-product-brief.md
|
||||||
|
- _bmad-output/planning-artifacts/prds/prd-sar-2026-05-27/prd.md
|
||||||
|
- design-artifacts/C-UX-Scenarios/00-ux-scenarios.md
|
||||||
|
- design-artifacts/_progress/wds-project-outline.yaml
|
||||||
|
workflowType: 'architecture'
|
||||||
|
project_name: 'SAR — Força de Vendas'
|
||||||
|
user_name: 'Julian'
|
||||||
|
date: '2026-05-30'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documento de Decisões de Arquitetura
|
||||||
|
# SAR — Força de Vendas
|
||||||
|
|
||||||
|
> Referência canônica para agentes de IA e devs implementando histórias. Cada decisão aqui é lei — não reabrir sem RFC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visão geral do sistema
|
||||||
|
|
||||||
|
SAR é um SaaS B2B de força de vendas multi-tenant. Arquitetura web-first com PWA para o cockpit Rep (mobile), desktop para Supervisor/Dono. MVP entrega cockpits Rafael (Rep) e Sandra (Supervisor).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser (PWA/Desktop) │
|
||||||
|
│ React 19.2 + TanStack Router/Query + Zustand + AntD │
|
||||||
|
└──────────────────────┬──────────────────────────────────┘
|
||||||
|
│ REST /api/v1 (+WebSocket/SSE futuro)
|
||||||
|
┌──────────────────────▼──────────────────────────────────┐
|
||||||
|
│ NestJS 11 API (apps/api) │
|
||||||
|
│ JwtAuthGuard → WorkspaceCLS → Module handlers │
|
||||||
|
└──────────────────────┬──────────────────────────────────┘
|
||||||
|
│ Prisma 7 (pool por empresa)
|
||||||
|
┌──────────────────────▼──────────────────────────────────┐
|
||||||
|
│ PostgreSQL 18 (schema "sar" + views ERP) │
|
||||||
|
│ Um schema por empresa (idEmpresa) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Estrutura do monorepo
|
||||||
|
|
||||||
|
```
|
||||||
|
sar/
|
||||||
|
├── apps/
|
||||||
|
│ ├── api/ ← NestJS (backend)
|
||||||
|
│ └── web/ ← React Vite (frontend)
|
||||||
|
├── libs/
|
||||||
|
│ └── shared/
|
||||||
|
│ └── api-interface/ ← contratos Zod compartilhados
|
||||||
|
├── STACK.md ← fonte da verdade técnica
|
||||||
|
└── CODING-RULES.md ← invariantes e pegadinhas
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regra:** Qualquer tipo que cruza a fronteira API↔Web mora em `libs/shared/api-interface`. Nunca duplicar tipos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Multi-tenancy
|
||||||
|
|
||||||
|
**Modelo:** `idEmpresa: number` (inteiro do ERP) identifica o tenant. Não existe `workspaceId: string` — o ADR 0006 original foi revogado.
|
||||||
|
|
||||||
|
**Mecanismo no backend:**
|
||||||
|
- `WorkspaceModule` registra CLS global (nestjs-cls)
|
||||||
|
- CLS middleware injeta `requestId` e `idEmpresa = 0` (fallback para rotas públicas)
|
||||||
|
- `JwtAuthGuard` após validar o JWT sobrescreve `idEmpresa`, `userId`, `role` e injeta o `PrismaClient` certo no CLS
|
||||||
|
- Todo handler acessa `cls.get('prisma')` — nunca um Prisma singleton (CODING-RULES PGD-DB-009)
|
||||||
|
|
||||||
|
**No banco:**
|
||||||
|
- Schema `sar` contém todas as tabelas SAR
|
||||||
|
- Views `vw_*` expõem dados do ERP legado (leitura apenas)
|
||||||
|
- Isolation por `id_empresa` nas queries — nenhuma query atravessa empresas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Módulos da API (NestJS)
|
||||||
|
|
||||||
|
| Módulo | Responsabilidade |
|
||||||
|
|--------|-----------------|
|
||||||
|
| `auth` | `/auth/me` — perfil do usuário autenticado; `/dev-auth` — login dev (sem master-login) |
|
||||||
|
| `workspace` | CLS global, pool de Prisma por empresa |
|
||||||
|
| `catalog` | Catálogo de produtos, empresa (pauta/preço) |
|
||||||
|
| `clients` | Lista e ficha de clientes |
|
||||||
|
| `orders` | CRUD pedidos, fluxo aprovação, histórico |
|
||||||
|
| `dashboard` | KPIs do Rep e Supervisor |
|
||||||
|
| `notifications` | Web Push (futuro SSE/Socket.IO) |
|
||||||
|
| `health` | `/health` — liveness/readiness |
|
||||||
|
| `ping` | `/ping` — sanity check |
|
||||||
|
| `logger` | Pino configurado com redact de PII |
|
||||||
|
|
||||||
|
**Padrão de módulo:**
|
||||||
|
```
|
||||||
|
módulo/
|
||||||
|
├── *.module.ts
|
||||||
|
├── *.controller.ts ← valida entrada com contrato Zod
|
||||||
|
├── *.service.ts ← lógica de domínio
|
||||||
|
└── *.types.ts ← tipos internos (não exportados para o shared)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Contratos Zod (API Interface)
|
||||||
|
|
||||||
|
Localização: `libs/shared/api-interface/src/lib/*.contract.ts`
|
||||||
|
|
||||||
|
Contratos existentes: `auth`, `client`, `company`, `dashboard`, `notifications`, `order`, `ping`, `product`
|
||||||
|
|
||||||
|
**Padrão de contrato:**
|
||||||
|
```typescript
|
||||||
|
// Schema de entrada (mutation)
|
||||||
|
export const CreateXxxSchema = z.object({ ... });
|
||||||
|
export type CreateXxx = z.infer<typeof CreateXxxSchema>;
|
||||||
|
|
||||||
|
// Schema de saída (query)
|
||||||
|
export const XxxResponseSchema = z.object({ ... });
|
||||||
|
export type XxxResponse = z.infer<typeof XxxResponseSchema>;
|
||||||
|
|
||||||
|
// Schema de lista (com paginação)
|
||||||
|
export const XxxListQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
});
|
||||||
|
export const XxxListResponseSchema = z.object({
|
||||||
|
data: z.array(XxxResponseSchema),
|
||||||
|
total: z.number().int().nonneg(),
|
||||||
|
page: z.number().int().positive(),
|
||||||
|
limit: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regra:** Backend valida entrada com o Schema (422 se inválido — RFC 9457). Frontend parseia resposta com o Schema (`Schema.parse(data)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Autenticação e autorização
|
||||||
|
|
||||||
|
**Fluxo MVP (dev):**
|
||||||
|
- Token JWT em `localStorage` via `authStore` (transitório — dev only)
|
||||||
|
- `POST /api/v1/auth/dev-login` retorna JWT assinado
|
||||||
|
- Bearer token no header de toda requisição protegida
|
||||||
|
|
||||||
|
**Fluxo produção (planejado):**
|
||||||
|
- master-login IdP OAuth2/OIDC (JCS próprio)
|
||||||
|
- Access token em memória; refresh token em cookie `httpOnly; Secure; SameSite=Lax`
|
||||||
|
- `JwtAuthGuard` valida e injeta contexto no CLS
|
||||||
|
|
||||||
|
**Papéis (roles):** `rep` · `supervisor` · `dono` · `admin`
|
||||||
|
|
||||||
|
**Frontend — role routing:**
|
||||||
|
- Raiz `/` → lê role do JWT payload, redireciona para `<RepPainel>` ou `<SupervisorPainel>`
|
||||||
|
- Cada cockpit renderiza apenas suas rotas; sem RBAC granular no MVP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Frontend — estrutura
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/src/
|
||||||
|
├── cockpits/
|
||||||
|
│ ├── rep/ ← páginas do cockpit Rafael
|
||||||
|
│ │ ├── RepPainel.tsx
|
||||||
|
│ │ ├── ClientsPage.tsx
|
||||||
|
│ │ ├── ClientDetailPage.tsx
|
||||||
|
│ │ ├── OrdersPage.tsx
|
||||||
|
│ │ ├── OrderDetailPage.tsx
|
||||||
|
│ │ ├── OrderPrintPage.tsx
|
||||||
|
│ │ ├── NewOrderPage.tsx
|
||||||
|
│ │ └── CatalogPage.tsx
|
||||||
|
│ └── supervisor/ ← páginas do cockpit Sandra
|
||||||
|
│ ├── SupervisorPainel.tsx
|
||||||
|
│ └── ApprovalQueuePage.tsx
|
||||||
|
├── components/
|
||||||
|
│ └── layout/ ← AppShell, Sidebar, Topbar
|
||||||
|
├── lib/
|
||||||
|
│ ├── router.tsx ← TanStack Router (flat routes)
|
||||||
|
│ ├── auth-store.ts ← Zustand-lite para token
|
||||||
|
│ ├── api-client.ts ← apiFetch + ApiError (RFC 9457)
|
||||||
|
│ ├── query-client.ts← TanStack Query config
|
||||||
|
│ ├── queries/ ← hooks de query por domínio
|
||||||
|
│ ├── hooks/ ← hooks utilitários
|
||||||
|
│ └── theme.ts ← AntD theme config
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rotas atuais:**
|
||||||
|
|
||||||
|
| Path | Componente | Cockpit |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| `/` | HomeRoute (role redirect) | — |
|
||||||
|
| `/rep` | RepPainel | Rep |
|
||||||
|
| `/clientes` | ClientsPage | Rep |
|
||||||
|
| `/clientes/$id` | ClientDetailPage | Rep |
|
||||||
|
| `/pedidos` | OrdersPage | Rep |
|
||||||
|
| `/pedidos/novo` | NewOrderPage | Rep |
|
||||||
|
| `/pedidos/$id` | OrderDetailPage | Rep |
|
||||||
|
| `/pedidos/$id/imprimir` | OrderPrintPage | Rep |
|
||||||
|
| `/catalogo` | CatalogPage | Rep |
|
||||||
|
| `/aprovacoes` | ApprovalQueuePage | Supervisor |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Gerenciamento de estado
|
||||||
|
|
||||||
|
| Tipo de estado | Solução |
|
||||||
|
|----------------|---------|
|
||||||
|
| Server state (queries, mutations) | TanStack Query v5 |
|
||||||
|
| Auth (token, user profile) | Zustand (`authStore`) |
|
||||||
|
| UI state local (modais, forms) | `useState` / `useReducer` em componente |
|
||||||
|
| Offline queue (futuro) | IndexedDB + Service Worker |
|
||||||
|
|
||||||
|
**Regra:** Não duplicar server state em Zustand. TanStack Query é a fonte da verdade para dados do servidor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API Client (frontend)
|
||||||
|
|
||||||
|
`lib/api-client.ts` — `apiFetch(path, options)`:
|
||||||
|
- Base URL: `/api/v1` (proxy Vite em dev → `:3000`; Nginx em prod → mesmo origin)
|
||||||
|
- Injeta `Authorization: Bearer <token>` automaticamente
|
||||||
|
- Parseia `application/problem+json` → `ApiError` com `status` + `problem`
|
||||||
|
- Não faz parse Zod — caller é responsável
|
||||||
|
|
||||||
|
**Tratamento de erro:**
|
||||||
|
- `422` = validação Zod (erros detalhados em `problem.errors`)
|
||||||
|
- `4xx` outros = erro de domínio
|
||||||
|
- `5xx` = retry automático pelo QueryClient (máx 2x)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Erros e RFC 9457
|
||||||
|
|
||||||
|
Todo erro da API retorna `application/problem+json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "https://sar.jcsinformatica.com.br/errors/validation-error",
|
||||||
|
"title": "Validation Error",
|
||||||
|
"status": 422,
|
||||||
|
"detail": "Campo obrigatório ausente",
|
||||||
|
"requestId": "uuid",
|
||||||
|
"errors": [{ "path": "idCliente", "message": "Required", "code": "invalid_type" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regra (CODING-RULES):** Nunca retornar `403` para recurso inexistente — usar `404` para não vazar existência.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Ciclo de vida do Pedido
|
||||||
|
|
||||||
|
Estados controlados pelo SAR:
|
||||||
|
|
||||||
|
```
|
||||||
|
0 (Orçamento) ──→ 1 (Ag. Aprovação) ──→ 2 (Transmitido)
|
||||||
|
│
|
||||||
|
└──→ 0 (recusado → volta Orçamento)
|
||||||
|
```
|
||||||
|
|
||||||
|
Estados espelhados do ERP (pós-integração): `3=Cancelado`, `4=Faturado`
|
||||||
|
|
||||||
|
**Alçada:** se `descontoPerc > alçadaRep` → situa vai para `1`. Supervisor aprova/recusa/ajusta.
|
||||||
|
|
||||||
|
**Idempotency-Key:** gerado no frontend antes do envio; garante que retentativas de sync não duplicam pedidos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Impressão de pedido
|
||||||
|
|
||||||
|
Rota `/pedidos/$id/imprimir` → `OrderPrintPage.tsx` — renderiza HTML otimizado para `window.print()` (browser → PDF). Sem geração server-side de PDF no MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Offline (Rafael) — planejado
|
||||||
|
|
||||||
|
**Mecanismo:** IndexedDB queue + Service Worker
|
||||||
|
- Catálogo e clientes cacheados com TTL 4h
|
||||||
|
- Pedidos criados offline enfileirados com `Idempotency-Key` local
|
||||||
|
- Sync automático ao retorno de sinal
|
||||||
|
- Dados financeiros sensíveis (limite de crédito numérico, inadimplência) só online
|
||||||
|
|
||||||
|
**Status atual:** não implementado. Será uma epic dedicada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Real-time (Sandra) — planejado
|
||||||
|
|
||||||
|
**Mecanismo:** Socket.IO 4 / SSE via `notifications` module
|
||||||
|
- Novos pedidos aparecem em < 3s no painel Sandra
|
||||||
|
- Push notifications Web Push (PWA) para aprovações
|
||||||
|
|
||||||
|
**Status atual:** `notifications.module.ts` existe, endpoints WIP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Banco de dados
|
||||||
|
|
||||||
|
- PostgreSQL 18, schema `sar`
|
||||||
|
- Prisma 7 como ORM/query builder
|
||||||
|
- Pool de clientes Prisma gerenciado pelo `WorkspacePrismaPool` (CLS)
|
||||||
|
- Views `vw_clientes`, `vw_representantes`, `vw_pedidos_erp` expõem dados legados
|
||||||
|
- Isolation por `id_empresa` em todas as queries
|
||||||
|
|
||||||
|
**Regra:** Raw SQL via `prisma.$queryRaw` apenas quando Prisma ORM não atende (ex: JOINs com views legadas).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Infraestrutura
|
||||||
|
|
||||||
|
| Componente | Solução |
|
||||||
|
|-----------|---------|
|
||||||
|
| Hosting | Proxmox on-prem Brasil |
|
||||||
|
| Orquestração | Docker Compose |
|
||||||
|
| Deploy | Ansible |
|
||||||
|
| CDN / proxy | Cloudflare + Nginx |
|
||||||
|
| Object storage | MinIO |
|
||||||
|
| Secrets | Vault |
|
||||||
|
| Cache / filas | Valkey (Redis-compatible) + BullMQ 5.77 |
|
||||||
|
| Observabilidade | OpenTelemetry + Pino |
|
||||||
|
| E-mail | Resend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Invariantes de implementação (CODING-RULES)
|
||||||
|
|
||||||
|
- `PrismaClient` sempre via `cls.get('prisma')` — nunca singleton injetado diretamente
|
||||||
|
- `idEmpresa` real vem do JWT (guard) — nunca de `.env` ou parâmetro de rota
|
||||||
|
- PII (CPF/CNPJ, telefone, e-mail) redactada em todos os logs via Pino `redact`
|
||||||
|
- Tokens JWT: acesso em memória, refresh em cookie `httpOnly; Secure; SameSite=Lax`
|
||||||
|
- Rate limit em auth: 5 tentativas/min/IP
|
||||||
|
- Nomes visíveis em toda UI — nunca exibir só código (cliente, rep, produto)
|
||||||
|
- Empresa matriz `9001` normalizada → `1` em consultas de catálogo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Decisões em aberto (não resolver sem RFC)
|
||||||
|
|
||||||
|
| # | Questão |
|
||||||
|
|---|---------|
|
||||||
|
| OQ-1 | Formato e frequência de importação do catálogo/clientes do ERP legado |
|
||||||
|
| OQ-2 | Alçada de desconto: fixa por rep ou por linha de produto? |
|
||||||
|
| OQ-3 | Fórmula de cálculo de comissão FLEX documentada? |
|
||||||
|
| OQ-5 | Múltiplos supervisores: distribuição da fila de Aprovações |
|
||||||
|
| OQ-6 | TTL de 4h para catálogo offline é aceitável para o 1º cliente? |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Gerado em 2026-05-30. Baseado no código existente + PRD 2026-05-27 + Product Brief 2026-05-26.*
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Decision Log — SAR PRD MVP
|
||||||
|
|
||||||
|
**Workspace:** `_bmad-output/planning-artifacts/prds/prd-sar-2026-05-27/`
|
||||||
|
**Iniciado:** 2026-05-27
|
||||||
|
**Facilitador:** bmad-prd (Create · Fast Path)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisões
|
||||||
|
|
||||||
|
### D-001 — Escopo MVP: Rafael + Sandra apenas
|
||||||
|
**Data:** 2026-05-27
|
||||||
|
**Decisão:** MVP cobre cockpits Rafael (Representante) e Sandra (Supervisora). Daniel e Alice têm telas placeholder.
|
||||||
|
**Justificativa:** Julian definiu explicitamente: consulta de clientes, pedidos históricos, lançamento de pedido novo. Sandra foi incluída porque aprovação de desconto é inseparável do fluxo de pedido do Rafael.
|
||||||
|
**Impacto:** Cockpits Daniel e Alice ficam fora do escopo funcional.
|
||||||
|
|
||||||
|
### D-002 — Aprovação de desconto inclusa no MVP
|
||||||
|
**Data:** 2026-05-27
|
||||||
|
**Decisão:** Fluxo completo de aprovação (Rafael solicita → Sandra aprova/recusa) está no MVP (C5).
|
||||||
|
**Justificativa:** Julian confirmou explicitamente ao ser perguntado.
|
||||||
|
**Impacto:** Sandra precisa de cockpit funcional (painel + fila de aprovações + push).
|
||||||
|
|
||||||
|
### D-003 — WhatsApp via Share API nativa no MVP
|
||||||
|
**Data:** 2026-05-27
|
||||||
|
**Decisão:** Sem Meta Cloud API no MVP. Compartilhamento via Web Share API (abre WhatsApp nativo do device).
|
||||||
|
**Justificativa:** Reduz complexidade e custo de integração para o MVP. Meta Cloud API entra na próxima iteração.
|
||||||
|
**Impacto:** Nenhuma mensagem programática para clientes finais no MVP.
|
||||||
|
|
||||||
|
### D-004 — ERP: importação manual no MVP
|
||||||
|
**Data:** 2026-05-27
|
||||||
|
**Decisão:** Catálogo, pautas e clientes importados via arquivo (CSV/JSON) ou endpoint simples. Sem integração automática bidirecional no MVP.
|
||||||
|
**Justificativa:** Reduz escopo e depende do ERP específico do primeiro cliente (a definir).
|
||||||
|
**Impacto:** OQ-1 precisa ser resolvido com o primeiro cliente antes de C4.
|
||||||
|
|
||||||
|
### D-005 — Working mode: Fast Path
|
||||||
|
**Data:** 2026-05-27
|
||||||
|
**Decisão:** Julian optou por Fast Path (terse brief, referência a documentos existentes como fonte de verdade).
|
||||||
|
**Justificativa:** Documentos do Phase 1+2 (Brief + Trigger Map) estavam completos e detalhados. Extração via subagentes foi suficiente para draft completo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-006 — Fixes do reviewer gate aplicados
|
||||||
|
**Data:** 2026-05-27
|
||||||
|
**Decisão:** 3 achados críticos do reviewer incorporados no PRD antes do `status: final`:
|
||||||
|
1. FR-2.4/FR-2.5 + NFR-3.3 — contradição sobre offline resolvida: situação resumida cacheada; limite numérico e inadimplência requerem conexão com disclaimer.
|
||||||
|
2. FR-4.11 (renumerado para FR-4.12) — adicionado FR para falha de sync: Pedido retorna ao Rep com status `falha de sync` + motivo legível, nunca descartado silenciosamente.
|
||||||
|
3. FR-4.7 — adicionada nota [OQ-2] para alçada por linha de produto.
|
||||||
|
|
||||||
|
### D-007 — OQs triadas: non-blockers para PRD final
|
||||||
|
**Data:** 2026-05-27
|
||||||
|
**Decisão:** PRD finalizado com todas as 6 OQs abertas. Classificação:
|
||||||
|
- **Phase-blockers para epics específicos** (não para o PRD): OQ-1 (C4), OQ-2 (C4/C5), OQ-4 (C2/C4), OQ-5 (C5)
|
||||||
|
- **Non-blockers**: OQ-3 (C7 — comissão FLEX pode ser placeholder), OQ-6 (TTL configurável)
|
||||||
|
- OQ-1 e OQ-4 dependem do primeiro cliente — devem ser resolvidas antes do início do design de C2/C4.
|
||||||
|
|
||||||
|
### D-008 — PRD finalizado
|
||||||
|
**Data:** 2026-05-27
|
||||||
|
**Status:** `final`
|
||||||
|
**Artefatos:** `prd.md` + `.decision-log.md` + `addendum.md` (não gerado — sem overflow de conteúdo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions abertas (a confirmar com Julian)
|
||||||
|
|
||||||
|
| ID | Assumption | Seção |
|
||||||
|
|----|-----------|-------|
|
||||||
|
| A-001 | Criação de usuários feita por admin JCS (sem self-service) | FR-1.5 |
|
||||||
|
| A-002 | Thresholds de inatividade (30/60d) configuráveis por workspace | FR-2.3 |
|
||||||
|
| A-003 | Catálogo de clientes sincronizado do ERP; Rafael não cria/edita | FR-2.6 |
|
||||||
|
| A-004 | Sync de catálogo com TTL de 4h | FR-4.4 |
|
||||||
|
| A-005 | Alçada de desconto default: 5% | FR-4.11 |
|
||||||
|
| A-006 | Qualquer supervisor do workspace pode aprovar qualquer pedido da equipe | FR-5.6 |
|
||||||
|
| A-007 | Sandra não acessa fichas de clientes individuais no MVP | FR-8.3 |
|
||||||
|
| A-008 | Sem suporte a browsers legados (IE etc.) | NFR-5.3 |
|
||||||
416
_bmad-output/planning-artifacts/prds/prd-sar-2026-05-27/prd.md
Normal file
416
_bmad-output/planning-artifacts/prds/prd-sar-2026-05-27/prd.md
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
---
|
||||||
|
title: "SAR — Força de Vendas: PRD MVP"
|
||||||
|
status: final
|
||||||
|
created: 2026-05-27
|
||||||
|
updated: 2026-05-27
|
||||||
|
project: sar
|
||||||
|
version: "0.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
# SAR — Força de Vendas: PRD MVP
|
||||||
|
|
||||||
|
## 1. Visão Geral
|
||||||
|
|
||||||
|
**SAR** é uma plataforma SaaS web de força de vendas para PMEs brasileiras com 5–50 Representantes externos. Substitui o app Android/Desktop legado da JCS e resolve a dor central: **donos e supervisores decidem no escuro** enquanto 5–10% da Carteira esfria silenciosamente na rua.
|
||||||
|
|
||||||
|
O produto entrega **quatro cockpits especializados** (Representante · Supervisor · Dono · Admin) compartilhando um único dado em tempo real. O MVP valida os dois cockpits de maior impacto operacional imediato: **Rafael** (Rep em campo) e **Sandra** (supervisora que aprova e monitora).
|
||||||
|
|
||||||
|
**Por que agora:** janela de mercado de 2–3 anos antes de concorrentes se modernizarem. O primeiro cliente pagante em 3–4 meses valida o modelo e financia as próximas iterações.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Objetivos e Métricas de Sucesso
|
||||||
|
|
||||||
|
### North Star
|
||||||
|
> Primeiro cliente paga e **renova** nos primeiros 3 meses após go-live.
|
||||||
|
|
||||||
|
### Métricas de comportamento (MVP)
|
||||||
|
|
||||||
|
| Métrica | Alvo MVP | Sinal de problema |
|
||||||
|
|---------|----------|-------------------|
|
||||||
|
| Pedidos lançados pelo Rep no SAR vs. total | ≥ 90% | < 70% = adoção baixa |
|
||||||
|
| Tempo médio de lançamento de Pedido | < 2 min | > 5 min = UX ruim |
|
||||||
|
| Taxa de sincronização offline bem-sucedida | ≥ 99,5% | < 99% = risco de perda |
|
||||||
|
| Aprovações respondidas em < 30 min | ≥ 80% | < 60% = gargalo |
|
||||||
|
| Clientes Inativos >60 d visualizados/semana | ≥ 30% da lista | < 10% = feature ignorada |
|
||||||
|
|
||||||
|
### Métricas de negócio JCS (Y1)
|
||||||
|
- 10–20 clientes pagantes até o mês 12
|
||||||
|
- ARR R$ 200k–600k
|
||||||
|
- NPS donos > 50
|
||||||
|
|
||||||
|
### Counter-métricas
|
||||||
|
- Pedidos duplicados por falha de sync → alvo: 0
|
||||||
|
- Reclamações de dado incorreto no histórico → alvo: < 2/mês/cliente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Personas MVP
|
||||||
|
|
||||||
|
### Rafael — Representante *(cockpit primário)*
|
||||||
|
|
||||||
|
- **Perfil:** 30–50 anos, vendedor B2B externo, comissionado, atende 50–200 Clientes ativos
|
||||||
|
- **Device:** Mobile-first · PWA iOS (Android legado continua em paralelo)
|
||||||
|
- **Contexto de uso:** No carro, no posto, no fundo da loja. Conexão instável 3G/4G. Raramente na mesa.
|
||||||
|
- **Goals prioritários:**
|
||||||
|
- Bater meta mensal sem depender do escritório para informações
|
||||||
|
- Ter clareza absoluta da Carteira — saber sem pensar quem comprou, quando e quanto
|
||||||
|
- Lançar Pedido em menos de 60 segundos, mesmo sem sinal
|
||||||
|
- **Frustrações que o produto resolve:**
|
||||||
|
- Perde Pedido por bug ou ausência de sinal no app legado
|
||||||
|
- Comissão é mistério até o fechamento do mês
|
||||||
|
- Não sabe quais Clientes estão esfriando antes que seja tarde demais
|
||||||
|
- Precisa ligar para o escritório para consultar histórico de Cliente
|
||||||
|
|
||||||
|
### Sandra — Supervisora *(cockpit secundário)*
|
||||||
|
|
||||||
|
- **Perfil:** 35–55 anos, gerente comercial, coordena 5–30 Representantes, desktop o dia todo
|
||||||
|
- **Device:** Desktop-first · PWA mobile-light (Aprovações em reuniões/almoço)
|
||||||
|
- **Contexto de uso:** Abre o SAR às 8h30. Checagem diária, Aprovações durante reuniões, fechamento de semana.
|
||||||
|
- **Goals prioritários:**
|
||||||
|
- Saber o que está acontecendo na rua sem precisar ligar para os Reps
|
||||||
|
- Aprovar descontos com contexto real (histórico, margem, alçada) em segundos
|
||||||
|
- Identificar problemas no time antes que virem perda de Cliente
|
||||||
|
- **Frustrações que o produto resolve:**
|
||||||
|
- Aprova desconto no WhatsApp sem nenhum contexto
|
||||||
|
- Descobre tarde demais que um Cliente-chave parou de comprar
|
||||||
|
- Não tem visão consolidada dos Pedidos do dia em tempo real
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Escopo do MVP
|
||||||
|
|
||||||
|
### Dentro do escopo
|
||||||
|
|
||||||
|
| # | Capacidade | Cockpit |
|
||||||
|
|---|-----------|---------|
|
||||||
|
| C1 | Autenticação e acesso por papel | Todos |
|
||||||
|
| C2 | Consulta de Clientes (lista + ficha) | Rafael |
|
||||||
|
| C3 | Consulta de Pedidos históricos | Rafael |
|
||||||
|
| C4 | Lançamento de Pedido novo (online + offline) | Rafael |
|
||||||
|
| C5 | Fluxo de Aprovação de desconto | Rafael → Sandra |
|
||||||
|
| C6 | Push notification de Aprovação | Sandra → Rafael |
|
||||||
|
| C7 | Painel Rafael (meta, KPIs, alertas de Inativo) | Rafael |
|
||||||
|
| C8 | Painel Sandra (Pedidos do dia, fila de Aprovações) | Sandra |
|
||||||
|
| C9 | Onboarding de workspace (admin interno JCS) | — |
|
||||||
|
|
||||||
|
### Fora do escopo (MVP)
|
||||||
|
|
||||||
|
- Cockpit Daniel (BI + IA estratégica) → placeholder de tela apenas
|
||||||
|
- Cockpit Alice (campanhas, ICMS-ST, pautas) → placeholder de tela apenas
|
||||||
|
- Editor visual de campanhas no-code
|
||||||
|
- Assistente IA por NCM/UF para ICMS-ST
|
||||||
|
- WhatsApp conversacional bidirecional (apenas Share API nativo)
|
||||||
|
- App nativo iOS/Android
|
||||||
|
- Integração automática com ERPs externos
|
||||||
|
- Benchmark cross-tenant
|
||||||
|
- Agenda + check-in GPS *(desejável; entra se não comprometer prazo)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Jornadas de Usuário
|
||||||
|
|
||||||
|
### UJ-1 — Rafael lança Pedido em campo (fluxo principal)
|
||||||
|
|
||||||
|
> Rafael está na frente do comprador na distribuidora. Abre o SAR no celular (4G fraco).
|
||||||
|
|
||||||
|
1. Rafael abre a ficha do Cliente pelo nome — vê histórico e limite de crédito disponível
|
||||||
|
2. Toca em "Novo Pedido" — catálogo carrega do cache offline
|
||||||
|
3. Adiciona produtos por busca ou lista de favoritos
|
||||||
|
4. O sistema sugere o desconto máximo dentro da alçada de Rafael
|
||||||
|
5. Rafael aplica desconto de 8% (dentro da alçada de 10%) — Pedido vai direto para confirmação
|
||||||
|
6. Rafael confirma — Pedido entra na fila offline com Idempotency-Key
|
||||||
|
7. Ao recuperar sinal, o Pedido sincroniza automaticamente — Rafael recebe confirmação silenciosa
|
||||||
|
8. Cliente recebe resumo via Share API (WhatsApp nativo do celular de Rafael)
|
||||||
|
|
||||||
|
*Variação offline:* os passos 6 e 7 ocorrem com delay. O Pedido aparece como "pendente sincronização" na lista.
|
||||||
|
|
||||||
|
### UJ-2 — Rafael solicita Aprovação de desconto acima da alçada
|
||||||
|
|
||||||
|
> Rafael quer dar 15% de desconto; sua alçada é 10%.
|
||||||
|
|
||||||
|
1. Rafael digita 15% — o sistema avisa "acima da sua alçada (10%). Enviar para Aprovação?"
|
||||||
|
2. Rafael confirma — Pedido vai para Sandra com status `aprovação pendente`
|
||||||
|
3. Sandra recebe push notification com contexto: Cliente, valor, desconto solicitado, histórico
|
||||||
|
4. Sandra abre a notificação no celular (ou no Painel desktop) — vê histórico do Cliente
|
||||||
|
5. Sandra aprova ou recusa com um toque, podendo ajustar o percentual
|
||||||
|
6. Rafael recebe push notification: "Pedido #1234 aprovado — 13%" e o Pedido entra em sincronização
|
||||||
|
|
||||||
|
### UJ-3 — Sandra monitora o dia (fluxo diário)
|
||||||
|
|
||||||
|
> Sandra chega às 8h30 e abre o SAR no notebook.
|
||||||
|
|
||||||
|
1. Painel mostra: Pedidos do dia, fila de Aprovações pendentes, alertas de Inativos por Rep
|
||||||
|
2. Sandra vê que o Rep Marcos tem 3 Clientes Inativos há mais de 60 dias — acessa a lista
|
||||||
|
3. Abre a fila de Aprovações: 2 Pedidos aguardando. Resolve os dois com contexto visível
|
||||||
|
4. À tarde: recebe push no celular durante o almoço — Aprovação urgente. Resolve em segundos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Requisitos Funcionais
|
||||||
|
|
||||||
|
### C1 — Autenticação e Acesso
|
||||||
|
|
||||||
|
**FR-1.1** O sistema autentica usuários via master-login (IdP OAuth2/OIDC próprio da JCS).
|
||||||
|
|
||||||
|
**FR-1.2** Cada usuário tem exatamente um papel no workspace: `representante` · `supervisor` · `dono` · `admin`. O papel define qual cockpit é exibido e quais operações são permitidas.
|
||||||
|
|
||||||
|
**FR-1.3** O acesso a workspaces é isolado fisicamente por banco de dados (BD-por-workspace, ADR 0006). Nenhum usuário acessa dados de outro workspace, nem mesmo administradores da JCS.
|
||||||
|
|
||||||
|
**FR-1.4** O sistema bloqueia acesso e retorna HTTP 404 (não 403) quando o usuário autenticado não tem permissão sobre um recurso — para não vazar a existência do recurso.
|
||||||
|
|
||||||
|
**FR-1.5** [ASSUMPTION] Criação de usuários e workspaces é feita por administrador interno da JCS (não self-service no MVP). Onboarding assistido.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C2 — Consulta de Clientes
|
||||||
|
|
||||||
|
**FR-2.1** O Rep lista todos os Clientes da sua Carteira com busca por nome, razão social e CNPJ/CPF.
|
||||||
|
|
||||||
|
**FR-2.2** A lista exibe, para cada Cliente: nome, última compra (data + valor), status de atividade (`ativo` · `em alerta` · `inativo`) e se há Pedidos em aberto.
|
||||||
|
|
||||||
|
**FR-2.3** O status de atividade é calculado automaticamente: `inativo` = sem Pedido Faturado há mais de 60 dias; `em alerta` = 30–60 dias; `ativo` = menos de 30 dias. [ASSUMPTION] Thresholds configuráveis por workspace pelo admin.
|
||||||
|
|
||||||
|
**FR-2.4** Ao abrir a ficha do Cliente, o Rep vê:
|
||||||
|
- Dados cadastrais: nome, CNPJ/CPF, endereço de entrega, telefone, e-mail
|
||||||
|
- Situação financeira resumida (`regular` · `atenção` · `bloqueado`) — disponível offline (cacheada)
|
||||||
|
- Limite de crédito disponível (valor numérico) — **requer conexão**; exibido com disclaimer "dados de até [hora da última sync]" quando offline
|
||||||
|
- Histórico de inadimplência — **requer conexão**; não cacheado offline
|
||||||
|
- Últimos 10 Pedidos com status e valor
|
||||||
|
- Comissão gerada pelo Cliente no mês atual e no mês anterior
|
||||||
|
|
||||||
|
**FR-2.5** A lista de Clientes, dados cadastrais, situação financeira resumida e os últimos 10 Pedidos estão disponíveis offline após a última sincronização. Dados financeiros sensíveis (limite de crédito numérico, inadimplência) requerem conexão.
|
||||||
|
|
||||||
|
**FR-2.6** [ASSUMPTION] O cadastro de Clientes é sincronizado do ERP legado da empresa. O Rep não cria nem edita Clientes no MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C3 — Consulta de Pedidos Históricos
|
||||||
|
|
||||||
|
**FR-3.1** O Rep visualiza todos os Pedidos da sua Carteira com filtros por: Cliente, status, período (padrão: últimos 90 dias) e número do Pedido.
|
||||||
|
|
||||||
|
**FR-3.2** Cada Pedido exibe: número, Cliente, data de emissão, status (`orçamento` · `aprovação pendente` · `aprovado` · `faturado` · `cancelado`), valor total e desconto aplicado.
|
||||||
|
|
||||||
|
**FR-3.3** Ao abrir um Pedido, o Rep vê: itens (produto, quantidade, preço unitário, desconto, subtotal), status de Aprovação (com quem está e desde quando, se pendente) e histórico de alterações de status.
|
||||||
|
|
||||||
|
**FR-3.4** Pedidos com status `aprovação pendente` têm indicador visual destacado na lista.
|
||||||
|
|
||||||
|
**FR-3.5** O histórico dos últimos 90 dias está disponível offline após sincronização. Pedidos mais antigos requerem conexão.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C4 — Lançamento de Pedido Novo
|
||||||
|
|
||||||
|
**FR-4.1** O Rep inicia um novo Pedido a partir da ficha do Cliente ou da tela inicial.
|
||||||
|
|
||||||
|
**FR-4.2** O fluxo de lançamento funciona completamente offline. Pedidos criados sem conexão são enfileirados localmente (IndexedDB) e sincronizados automaticamente quando o sinal é restaurado.
|
||||||
|
|
||||||
|
**FR-4.3** Cada Pedido é identificado por um `Idempotency-Key` gerado localmente antes do envio, garantindo que retentativas de sync não criem Pedidos duplicados.
|
||||||
|
|
||||||
|
**FR-4.4** O catálogo de produtos (código, descrição, preço de tabela, estoque disponível, foto) fica cacheado localmente para uso offline. [ASSUMPTION] A sincronização do catálogo ocorre ao abrir o app com conexão ativa, com TTL de 4h.
|
||||||
|
|
||||||
|
**FR-4.5** O Rep adiciona produtos ao Pedido por: busca por nome/código, lista de favoritos pessoais ou lista dos produtos mais comprados pelo Cliente.
|
||||||
|
|
||||||
|
**FR-4.6** Para cada item, o Rep define quantidade e percentual de desconto. O sistema exibe o preço resultante e o subtotal em tempo real.
|
||||||
|
|
||||||
|
**FR-4.7** O sistema valida o desconto contra a alçada do Rep **antes** da submissão:
|
||||||
|
- Dentro da alçada: Pedido segue direto para confirmação (FR-4.8)
|
||||||
|
- Acima da alçada: Pedido entra no fluxo de Aprovação (C5)
|
||||||
|
- [OQ-2] Se a alçada variar por linha de produto, a validação ocorre item a item; o Pedido só vai para Aprovação se ao menos um item exceder a alçada da sua linha.
|
||||||
|
|
||||||
|
**FR-4.8** Na tela de confirmação, o Rep vê: resumo dos itens, valor total, desconto médio, status do limite de crédito do Cliente e botão "Confirmar Pedido".
|
||||||
|
|
||||||
|
**FR-4.9** Após confirmação, o sistema:
|
||||||
|
- Registra o Pedido com status `orçamento` (dentro da alçada) ou `aprovação pendente` (acima da alçada)
|
||||||
|
- Exibe confirmação imediata ao Rep, mesmo que a sincronização ainda não tenha ocorrido
|
||||||
|
- Disponibiliza opção de compartilhar resumo do Pedido via Share API (WhatsApp nativo)
|
||||||
|
|
||||||
|
**FR-4.10** O Rep visualiza Pedidos pendentes de sync em uma lista local, com indicação clara de "aguardando conexão".
|
||||||
|
|
||||||
|
**FR-4.11** Se o servidor rejeitar um Pedido da fila offline (produto inativo, Cliente bloqueado, pauta vencida), o Pedido retorna ao Rep com status `falha de sync` e motivo legível em linguagem humana. O Pedido nunca é descartado silenciosamente — fica visível na fila com opção de editar e reenviar ou cancelar.
|
||||||
|
|
||||||
|
**FR-4.12** [ASSUMPTION] A alçada de desconto é configurada por Rep pelo admin do workspace. Default: 5%.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C5 — Fluxo de Aprovação de Desconto
|
||||||
|
|
||||||
|
**FR-5.1** Quando um Pedido exige Aprovação, o supervisor responsável recebe push notification e o Pedido é incluído na fila de Aprovações.
|
||||||
|
|
||||||
|
**FR-5.2** A fila de Aprovações exibe, para cada Pedido pendente: Rep, Cliente, valor total, desconto solicitado vs. alçada, tempo aguardando e indicador de urgência (> 2h sem resposta).
|
||||||
|
|
||||||
|
**FR-5.3** Ao abrir um Pedido para aprovar, o supervisor vê:
|
||||||
|
- Resumo do Pedido (itens, valores, desconto)
|
||||||
|
- Histórico do Cliente: últimas compras, inadimplência, volume no período
|
||||||
|
- Alçada do Rep e justificativa do desconto (campo livre, opcional)
|
||||||
|
|
||||||
|
**FR-5.4** O supervisor pode: **aprovar** (com o desconto solicitado), **aprovar com ajuste** (definir percentual diferente) ou **recusar** (com motivo obrigatório).
|
||||||
|
|
||||||
|
**FR-5.5** Após a decisão:
|
||||||
|
- O Rep recebe push notification com o resultado
|
||||||
|
- O status do Pedido é atualizado em tempo real no Painel de ambos
|
||||||
|
- Se aprovado, o Pedido avança para status `aprovado`; se recusado, retorna ao Rep com o motivo
|
||||||
|
|
||||||
|
**FR-5.6** [ASSUMPTION] No MVP, qualquer supervisor do workspace pode aprovar qualquer Pedido da sua equipe. Hierarquia de Aprovação com múltiplos níveis é pós-MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C6 — Notificações e Push
|
||||||
|
|
||||||
|
**FR-6.1** O sistema envia Web Push Notification para:
|
||||||
|
- Sandra: novo Pedido aguardando Aprovação (com preview: Rep, Cliente, valor)
|
||||||
|
- Rafael: Aprovação concedida ou recusada (com resultado e eventual ajuste)
|
||||||
|
|
||||||
|
**FR-6.2** Notificações push funcionam com o navegador em background (PWA).
|
||||||
|
|
||||||
|
**FR-6.3** O sistema exibe um badge de contagem no ícone de notificações na Topbar com o total de itens pendentes de ação.
|
||||||
|
|
||||||
|
**FR-6.4** O Rep compartilha o resumo de um Pedido confirmado via Share API do browser (abre o WhatsApp nativo ou qualquer app de mensagens do dispositivo). [ASSUMPTION] O conteúdo compartilhado é texto formatado com os itens e o valor total; link para visualização futura é pós-MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C7 — Painel Rafael
|
||||||
|
|
||||||
|
**FR-7.1** O Painel do Rep exibe, ao abrir o app:
|
||||||
|
- Saudação com nome e data atual
|
||||||
|
- Meta do mês (valor atingido / valor total, percentual e progresso visual)
|
||||||
|
- Valor faltante para atingir a meta
|
||||||
|
- Comissão acumulada no mês (valor fixo + FLEX, quando aplicável)
|
||||||
|
|
||||||
|
**FR-7.2** O Painel lista os Clientes Inativos da Carteira do Rep, ordenados por dias sem compra (decrescente). Clientes com mais de 60 dias têm destaque visual.
|
||||||
|
|
||||||
|
**FR-7.3** O Painel exibe os Pedidos recentes (últimos 7 dias) com status e indicação de pendentes de sync.
|
||||||
|
|
||||||
|
**FR-7.4** Todos os dados do Painel são acessíveis offline após a última sincronização. A data e hora da última sync são visíveis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C8 — Painel Sandra
|
||||||
|
|
||||||
|
**FR-8.1** O Painel da supervisora exibe, ao abrir o app:
|
||||||
|
- Fila de Aprovações pendentes (ordenada por tempo aguardando)
|
||||||
|
- Resumo dos Pedidos do dia da equipe: total de Pedidos, valor consolidado e comparativo com a mesma semana do mês anterior
|
||||||
|
- Alertas de Clientes Inativos por Rep (top 3 Reps com maior número de Inativos)
|
||||||
|
|
||||||
|
**FR-8.2** O Painel atualiza em tempo real via Socket.IO/SSE: novos Pedidos, mudanças de status e novas Aprovações pendentes.
|
||||||
|
|
||||||
|
**FR-8.3** [ASSUMPTION] No MVP, Sandra não acessa fichas de Clientes individuais nem histórico de Pedidos por Rep diretamente — apenas o que aparece no Painel e na fila de Aprovações. Drill-down por Rep é próxima iteração.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Requisitos Não-Funcionais
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
**NFR-1.1** Operações CRUD (consulta de Cliente, histórico de Pedidos, confirmação de Pedido): p99 < 800 ms.
|
||||||
|
|
||||||
|
**NFR-1.2** Carregamento inicial do Painel (dados do dia): p99 < 2 s com conexão 4G.
|
||||||
|
|
||||||
|
**NFR-1.3** Sincronização de Pedido offline pendente após retorno de conexão: início do envio em menos de 5 s.
|
||||||
|
|
||||||
|
**NFR-1.4** Atualização em tempo real no Painel da Sandra (novo Pedido aparece): menos de 3 s após o evento.
|
||||||
|
|
||||||
|
### Offline (Rafael)
|
||||||
|
|
||||||
|
**NFR-2.1** Rafael consulta Clientes, visualiza histórico e lança Pedidos completos sem nenhuma conexão de rede, usando os dados da última sincronização.
|
||||||
|
|
||||||
|
**NFR-2.2** Pedidos criados offline são persistidos no IndexedDB com `Idempotency-Key` gerado localmente, garantindo envio único quando a conexão é restaurada.
|
||||||
|
|
||||||
|
**NFR-2.3** O app detecta automaticamente a perda e o retorno de conexão e sincroniza a fila sem ação do usuário.
|
||||||
|
|
||||||
|
**NFR-2.4** Em nenhuma circunstância um Pedido pode ser perdido silenciosamente. Falhas de sync devem ser exibidas visivelmente para o Rep.
|
||||||
|
|
||||||
|
### Segurança e LGPD
|
||||||
|
|
||||||
|
**NFR-3.1** PII de Clientes (CPF/CNPJ, telefone, e-mail) é redactada nos logs de aplicação.
|
||||||
|
|
||||||
|
**NFR-3.2** Dados de Clientes e Pedidos são fisicamente isolados por workspace (BD-por-workspace, ADR 0006). Nenhuma query atravessa workspaces.
|
||||||
|
|
||||||
|
**NFR-3.3** O armazenamento offline (IndexedDB) contém apenas os dados mínimos necessários para o fluxo de lançamento de Pedido. Dados financeiros sensíveis (limite de crédito completo, histórico de inadimplência) requerem conexão.
|
||||||
|
|
||||||
|
**NFR-3.4** Tokens JWT são armazenados em memória (nunca em localStorage). Refresh token em cookie `httpOnly; Secure; SameSite=Lax`.
|
||||||
|
|
||||||
|
**NFR-3.5** Rate limit em endpoints de autenticação: 5 tentativas/min/IP.
|
||||||
|
|
||||||
|
**NFR-3.6** Todo opt-in para Web Push é explícito, com possibilidade de revogar a qualquer momento.
|
||||||
|
|
||||||
|
### Acessibilidade
|
||||||
|
|
||||||
|
**NFR-4.1** Interfaces seguem WCAG AA para contraste, tamanho de alvo touch (mínimo 44 px) e suporte a leitor de tela.
|
||||||
|
|
||||||
|
**NFR-4.2** Score Lighthouse ≥ 90 em Performance, Acessibilidade e Best Practices — gate obrigatório de CI.
|
||||||
|
|
||||||
|
### Compatibilidade
|
||||||
|
|
||||||
|
**NFR-5.1** Rafael: Safari iOS 17+ e Chrome Android 120+ (PWA com offline, Web Push, Share API, Geolocation).
|
||||||
|
|
||||||
|
**NFR-5.2** Sandra: Chrome 120+ e Safari 17+ em desktop. Layout responsivo até 1280 px.
|
||||||
|
|
||||||
|
**NFR-5.3** [ASSUMPTION] Sem suporte a IE ou browsers legados.
|
||||||
|
|
||||||
|
### Disponibilidade
|
||||||
|
|
||||||
|
**NFR-6.1** SLA de disponibilidade: 99,5% em horário comercial (7h–21h BRT, seg–sáb).
|
||||||
|
|
||||||
|
**NFR-6.2** Janela de manutenção: domingos das 2h–6h BRT, comunicada com 48h de antecedência.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Integrações
|
||||||
|
|
||||||
|
### 8.1 Master-login (IdP JCS) — *Obrigatório*
|
||||||
|
Autenticação e gestão de usuários/workspaces via OAuth2/OIDC. Usuários e papéis são gerenciados pelo IdP; o SAR atua como resource server.
|
||||||
|
|
||||||
|
### 8.2 WhatsApp — *Share API nativa (MVP)*
|
||||||
|
O compartilhamento de Pedidos usa a Web Share API nativa do device (abre WhatsApp ou qualquer app de mensagens). Nenhuma integração com Meta Cloud API no MVP. [ASSUMPTION] WhatsApp Business API (mensagens programáticas) entra na próxima iteração.
|
||||||
|
|
||||||
|
### 8.3 ERP Legado — *Sync via importação*
|
||||||
|
[ASSUMPTION] Catálogo de produtos, pautas de preço e cadastro de Clientes são importados do ERP legado via arquivo (CSV/JSON) ou endpoint configurável pelo admin. O SAR é o sistema de registro dos Pedidos novos; o ERP continua sendo o sistema de faturamento. Integração bidirecional automática é pós-MVP.
|
||||||
|
|
||||||
|
### 8.4 Web Push — *Nativo*
|
||||||
|
Via Push API do browser + Service Worker. Sem provedor SaaS externo de push no MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Restrições Técnicas e de Design
|
||||||
|
|
||||||
|
- **Stack:** STACK.md v2.2 é canônica e imutável sem RFC. Node 24 · Nest 11 · Prisma 7 · React 19.2 · AntD 6.4 · PostgreSQL 18 · multi-tenancy BD-por-workspace.
|
||||||
|
- **Brand:** `brand.md` é canônico. Paleta JCS Blue `#004a99`, Plus Jakarta Sans, Topbar 80 px + Sidebar 260 px, radius 12/20 px.
|
||||||
|
- **Vocabulário do produto:** Cliente · Representante/Rep · Orçamento · Pedido · Faturado · Visita · Carteira · Inativo · Painel · Aprovação. Nunca "Lead", "Prospect" ou "Ticket".
|
||||||
|
- **Infraestrutura:** Proxmox on-prem Brasil. Sem AWS/GCP/Azure. Cloudflare como CDN/proxy.
|
||||||
|
- **LGPD by design:** datacenter BR, PII criptografada, redact em logs, Art. 18 implementado.
|
||||||
|
- **Ambiente de desenvolvimento:** Docker Compose dev com Postgres 18, Valkey 8, MinIO e Mailpit — todos com healthcheck validado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Questões Abertas
|
||||||
|
|
||||||
|
| # | Questão | Impacto | Responsável | Prazo |
|
||||||
|
|---|---------|---------|-------------|-------|
|
||||||
|
| OQ-1 | Catálogo de produtos e pautas de preço: formato e frequência de importação do ERP legado? | Alto — bloqueia FR-4.4 | Julian + primeiro cliente | Antes do design de C4 |
|
||||||
|
| OQ-2 | Alçada de desconto: é fixa por Rep ou pode variar por linha de produto? | Médio — impacta FR-4.7 e FR-4.11 | Julian | Antes de C4/C5 |
|
||||||
|
| OQ-3 | Comissão FLEX: a fórmula de cálculo está documentada? | Médio — impacta FR-7.1 | Julian | Antes de C7 |
|
||||||
|
| ~~OQ-4~~ | ~~Limite de crédito: é calculado no SAR ou importado do ERP?~~ | ✅ **RESOLVIDO 2026-05-27** — gerenciado no SAR (admin/supervisor define; SAR é fonte da verdade) | Julian | — |
|
||||||
|
| OQ-5 | Múltiplos supervisores: se houver mais de um no workspace, como se distribui a fila de Aprovações? | Médio — impacta FR-5.1 | Julian | Antes de C5 |
|
||||||
|
| OQ-6 | Catálogo offline: TTL de 4h é aceitável para o primeiro cliente? Há risco de Rep vender produto fora de pauta? | Médio — impacta FR-4.4 | Julian + primeiro cliente | Antes de C4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Fora do Escopo (explícito)
|
||||||
|
|
||||||
|
- Cockpit Daniel (dashboard executivo + IA estratégica) — tela placeholder apenas
|
||||||
|
- Cockpit Alice (campanhas, ICMS-ST, pautas, cadastros) — tela placeholder apenas
|
||||||
|
- Editor visual de campanhas no-code
|
||||||
|
- Assistente IA por NCM/UF
|
||||||
|
- WhatsApp conversacional bidirecional (Meta Cloud API)
|
||||||
|
- Agenda e roteamento de visitas com GPS
|
||||||
|
- App nativo iOS ou Android
|
||||||
|
- Integração automática bidirecional com ERP
|
||||||
|
- Multi-empresa por workspace (uma empresa por workspace no MVP)
|
||||||
|
- Benchmark cross-tenant anonimizado
|
||||||
|
- Dark mode *(desejável, não bloqueante)*
|
||||||
|
- Relatórios exportáveis (PDF/Excel)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento gerado em 2026-05-27 via bmad-prd (Fast Path). Assumptions marcados — revisar com Julian antes do início de C4 e C5.*
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "api-e2e",
|
"name": "api-e2e",
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
|
"tags": ["scope:api", "type:e2e", "domain:shared"],
|
||||||
"implicitDependencies": ["api"],
|
"implicitDependencies": ["api"],
|
||||||
"targets": {
|
"targets": {
|
||||||
"e2e": {
|
"e2e": {
|
||||||
|
|||||||
@@ -2,5 +2,14 @@
|
|||||||
"name": "@sar/api",
|
"name": "@sar/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)"
|
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)",
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"web-push": "^3.6.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/web-push": "^3.6.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
apps/api/prisma.config.ts
Normal file
15
apps/api/prisma.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Prisma 7 config — usado pelo CLI (migrate, generate, studio).
|
||||||
|
// Conexão de runtime fica no WorkspacePrismaPool (adapter por workspace).
|
||||||
|
// CODING-RULES PGD-DB-001: DATABASE_URL aponta direto ao PG na porta 5432 (sem PgBouncer).
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import { defineConfig } from 'prisma/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: path.join(import.meta.dirname, 'prisma/schema.prisma'),
|
||||||
|
datasource: {
|
||||||
|
// Prisma 7: url aqui serve apenas para o CLI (migrate/generate/studio).
|
||||||
|
// Runtime usa WorkspacePrismaPool → PrismaClient({ adapter: new PrismaPg(pool) }).
|
||||||
|
url: process.env['DATABASE_URL'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- CreateTable: sar.oportunidades (Funil de Vendas)
|
||||||
|
-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL
|
||||||
|
-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente.
|
||||||
|
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.oportunidades (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
id_empresa INTEGER NOT NULL,
|
||||||
|
cod_vendedor INTEGER NOT NULL,
|
||||||
|
id_cliente INTEGER,
|
||||||
|
nome_prospect VARCHAR(200),
|
||||||
|
empresa_prospect VARCHAR(200),
|
||||||
|
titulo VARCHAR(200) NOT NULL,
|
||||||
|
etapa VARCHAR(20) NOT NULL DEFAULT 'lead',
|
||||||
|
valor_estimado NUMERIC(15,2),
|
||||||
|
observacoes TEXT,
|
||||||
|
id_pedido UUID REFERENCES sar.pedidos(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_oport_vend ON sar.oportunidades(id_empresa, cod_vendedor);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_oport_etapa ON sar.oportunidades(etapa);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- CreateTable chamados
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.chamados (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
id_empresa INTEGER NOT NULL,
|
||||||
|
cod_vendedor INTEGER NOT NULL,
|
||||||
|
id_pedido UUID NOT NULL,
|
||||||
|
num_ped_sar INTEGER NOT NULL,
|
||||||
|
id_cliente INTEGER NOT NULL,
|
||||||
|
tipo VARCHAR(50) NOT NULL,
|
||||||
|
assunto VARCHAR(200) NOT NULL,
|
||||||
|
descricao TEXT NOT NULL,
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'aberto',
|
||||||
|
resolucao VARCHAR(50),
|
||||||
|
nota_resolucao TEXT,
|
||||||
|
itens_afetados JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_status ON sar.chamados(id_empresa, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chamados_pedido ON sar.chamados(id_pedido);
|
||||||
|
|
||||||
|
-- CreateTable chamado_mensagens
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.chamado_mensagens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
id_chamado INTEGER NOT NULL REFERENCES sar.chamados(id) ON DELETE CASCADE,
|
||||||
|
autor VARCHAR(10) NOT NULL,
|
||||||
|
texto TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagens(id_chamado);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Torna id_pedido e num_ped_sar opcionais (chamados podem referenciar pedidos ERP)
|
||||||
|
ALTER TABLE sar.chamados ALTER COLUMN id_pedido DROP NOT NULL;
|
||||||
|
ALTER TABLE sar.chamados ALTER COLUMN num_ped_sar DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Referência ao pedido ERP (numero inteiro do SIG)
|
||||||
|
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS num_ped_erp INTEGER;
|
||||||
|
|
||||||
|
-- Nome do cliente desnormalizado (evita join em toda consulta)
|
||||||
|
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS nome_cliente VARCHAR(200);
|
||||||
|
|
||||||
|
-- Remove index por id_pedido (agora nullable, menos útil como index único)
|
||||||
|
DROP INDEX IF EXISTS sar.idx_chamados_pedido;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Endereco de entrega alternativo do pedido (feature "novo pedido v2").
|
||||||
|
-- O campo existia no schema.prisma sem migration correspondente.
|
||||||
|
-- Idempotente: provision-client.ts roda sar-erp-schema.sql antes do migrate deploy.
|
||||||
|
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||||
|
ALTER TABLE sar.pedidos ADD COLUMN IF NOT EXISTS end_entrega TEXT;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable: sar.regras_desconto (Regras de Desconto - Politicas Comerciais)
|
||||||
|
-- Desconto liberado pelo gerente por grupo/subgrupo de produto e/ou
|
||||||
|
-- representante, com vigencia. Campos nulos = "todos".
|
||||||
|
-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL
|
||||||
|
-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente.
|
||||||
|
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
id_empresa INTEGER NOT NULL,
|
||||||
|
descricao VARCHAR(200) NOT NULL,
|
||||||
|
cod_grupo INTEGER,
|
||||||
|
cod_subgrupo INTEGER,
|
||||||
|
cod_vendedor INTEGER,
|
||||||
|
desc_pct NUMERIC(5,2) NOT NULL,
|
||||||
|
data_inicio DATE NOT NULL,
|
||||||
|
data_fim DATE NOT NULL,
|
||||||
|
ativa BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
created_by VARCHAR(100) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_regras_empresa ON sar.regras_desconto(id_empresa);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_regras_vigente ON sar.regras_desconto(id_empresa, data_fim) WHERE ativa;
|
||||||
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"
|
||||||
287
apps/api/prisma/schema.prisma
Normal file
287
apps/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
// SAR — Schema no banco ERP da JCS (schema `sar` dentro do PostgreSQL do SIG/gestao)
|
||||||
|
// ADR 0006 revogado: banco separado por workspace → schema `sar` no ERP JCS.
|
||||||
|
// O isolamento multi-tenant é por `id_empresa` em todas as tabelas.
|
||||||
|
//
|
||||||
|
// CODING-RULES PGD-DB-004: moduleFormat = "cjs" (NestJS é CJS)
|
||||||
|
// CODING-RULES PGD-DB-001: MIGRATION_DATABASE_URL aponta direto ao PG (sem PgBouncer)
|
||||||
|
// A URL de runtime deve incluir ?schema=sar (injetado pelo JwtAuthGuard via WorkspacePrismaPool)
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../../../node_modules/.prisma/client"
|
||||||
|
moduleFormat = "cjs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prisma 7: url removida do schema — conexão em prisma.config.ts (migrate)
|
||||||
|
// e no WorkspacePrismaPool via PrismaPg adapter (runtime).
|
||||||
|
// A URL de runtime inclui ?schema=sar para rotear ao schema correto.
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pedido (C3) ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Pedido emitido pelo Rep. Situa: 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado.
|
||||||
|
// idEmpresa: tenant (empresa no ERP). codVendedor: gestao.vendedor.codigo.
|
||||||
|
// idCliente: sig.corrent.id_corrent. numPedSar: sequencial SAR (SAR-NNNNN).
|
||||||
|
|
||||||
|
model Pedido {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
numPedSar String @unique @map("num_ped_sar")
|
||||||
|
idCliente Int @map("id_cliente")
|
||||||
|
codVendedor Int @map("cod_vendedor")
|
||||||
|
situa Int @default(1)
|
||||||
|
dtPedido DateTime @default(now()) @db.Date @map("dt_pedido")
|
||||||
|
idPauta Int? @map("id_pauta")
|
||||||
|
codFormapag Int? @map("cod_formapag")
|
||||||
|
totalProdutos Decimal @default(0) @db.Decimal(15, 2) @map("total_produtos")
|
||||||
|
totalIpi Decimal @default(0) @db.Decimal(15, 2) @map("total_ipi")
|
||||||
|
totalIcmsst Decimal @default(0) @db.Decimal(15, 2) @map("total_icmsst")
|
||||||
|
total Decimal @default(0) @db.Decimal(15, 2)
|
||||||
|
descontoPerc Decimal @default(0) @db.Decimal(5, 2) @map("desconto_perc")
|
||||||
|
descontoValor Decimal @default(0) @db.Decimal(15, 2) @map("desconto_valor")
|
||||||
|
acrescimo Decimal @default(0) @db.Decimal(15, 2)
|
||||||
|
comissao Decimal @default(0) @db.Decimal(15, 2)
|
||||||
|
pedFlex Decimal @default(0) @db.Decimal(15, 2) @map("ped_flex")
|
||||||
|
obs String?
|
||||||
|
endEntrega String? @map("end_entrega")
|
||||||
|
aprovadoPor Int? @map("aprovado_por")
|
||||||
|
aprovadoEm DateTime? @map("aprovado_em")
|
||||||
|
motivoRecusa String? @map("motivo_recusa")
|
||||||
|
idempotencyKey String? @unique @map("idempotency_key")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
itens PedidoItem[]
|
||||||
|
historico HistoricoPedido[]
|
||||||
|
|
||||||
|
@@index([idEmpresa])
|
||||||
|
@@index([codVendedor])
|
||||||
|
@@index([idCliente])
|
||||||
|
@@index([situa])
|
||||||
|
@@index([dtPedido])
|
||||||
|
@@map("pedidos")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PedidoItem (C3/C4) ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Item do pedido. Produto desnormalizado via idProduto (vw_produtos).
|
||||||
|
|
||||||
|
model PedidoItem {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
idPedido String @db.Uuid @map("id_pedido")
|
||||||
|
ordem Int
|
||||||
|
idProduto Int @map("id_produto")
|
||||||
|
codProduto String? @map("cod_produto")
|
||||||
|
descProduto String? @map("desc_produto")
|
||||||
|
qtd Decimal @db.Decimal(10, 3)
|
||||||
|
precoUnitario Decimal @db.Decimal(15, 2) @map("preco_unitario")
|
||||||
|
descontoPerc Decimal @default(0) @db.Decimal(5, 2) @map("desconto_perc")
|
||||||
|
descontoValor Decimal @default(0) @db.Decimal(15, 2) @map("desconto_valor")
|
||||||
|
precoPauta Decimal @default(0) @db.Decimal(15, 2) @map("preco_pauta")
|
||||||
|
comissao Decimal @default(0) @db.Decimal(15, 2)
|
||||||
|
vlFlex Decimal @default(0) @db.Decimal(15, 2) @map("vl_flex")
|
||||||
|
precoComIpi Decimal @default(0) @db.Decimal(15, 2) @map("preco_com_ipi")
|
||||||
|
vlIpi Decimal @default(0) @db.Decimal(15, 2) @map("vl_ipi")
|
||||||
|
vlIcmsst Decimal @default(0) @db.Decimal(15, 2) @map("vl_icmsst")
|
||||||
|
total Decimal @db.Decimal(15, 2)
|
||||||
|
|
||||||
|
pedido Pedido @relation(fields: [idPedido], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([idPedido])
|
||||||
|
@@map("pedido_itens")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HistoricoPedido (C3) ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Registro imutável de cada transição de situa. changedBy = cod_vendedor do ator.
|
||||||
|
|
||||||
|
model HistoricoPedido {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
idPedido String @db.Uuid @map("id_pedido")
|
||||||
|
situaAnterior Int? @map("situa_anterior")
|
||||||
|
situaNova Int @map("situa_nova")
|
||||||
|
changedBy Int @map("changed_by")
|
||||||
|
nota String?
|
||||||
|
changedAt DateTime @default(now()) @map("changed_at")
|
||||||
|
|
||||||
|
pedido Pedido @relation(fields: [idPedido], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([idPedido])
|
||||||
|
@@map("historico_pedido")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AlcadaDesconto (C4) ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Alçada de desconto por vendedor, empresa e grupo de produto.
|
||||||
|
// codGrupo = 0 → limite global/default do rep.
|
||||||
|
|
||||||
|
model AlcadaDesconto {
|
||||||
|
codVendedor Int @map("cod_vendedor")
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
codGrupo Int @default(0) @map("cod_grupo")
|
||||||
|
limitePerc Decimal @default(5) @db.Decimal(5, 2) @map("limite_perc")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@id([codVendedor, idEmpresa, codGrupo])
|
||||||
|
@@index([codVendedor, idEmpresa])
|
||||||
|
@@map("alcada_desconto")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MetaRepresentante (C7) ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Meta mensal e taxas de comissão por rep. Uma linha por rep/empresa/mês.
|
||||||
|
|
||||||
|
model MetaRepresentante {
|
||||||
|
codVendedor Int @map("cod_vendedor")
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
ano Int
|
||||||
|
mes Int
|
||||||
|
metaValor Decimal @db.Decimal(15, 2) @map("meta_valor")
|
||||||
|
taxaComissao Decimal @default(3) @db.Decimal(5, 2) @map("taxa_comissao")
|
||||||
|
taxaFlex Decimal @default(1) @db.Decimal(5, 2) @map("taxa_flex")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@id([codVendedor, idEmpresa, ano, mes])
|
||||||
|
@@index([codVendedor, idEmpresa])
|
||||||
|
@@map("meta_representante")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Promocao (C8) ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Promoção comercial com validade, criada pelo Gerente. Pode ser por produto
|
||||||
|
// ou por grupo de produto. descPct em % (ex.: 5.00 = 5%).
|
||||||
|
|
||||||
|
model Promocao {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
descricao String
|
||||||
|
codProduto String? @map("cod_produto")
|
||||||
|
grpProd String? @map("grp_prod")
|
||||||
|
descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
|
||||||
|
dataInicio DateTime @db.Date @map("data_inicio")
|
||||||
|
dataFim DateTime @db.Date @map("data_fim")
|
||||||
|
ativa Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
createdBy String @map("created_by")
|
||||||
|
|
||||||
|
@@index([idEmpresa])
|
||||||
|
@@index([idEmpresa, dataFim])
|
||||||
|
@@map("promocoes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
|
||||||
|
// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos".
|
||||||
|
|
||||||
|
model RegraDesconto {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
descricao String
|
||||||
|
codGrupo Int? @map("cod_grupo")
|
||||||
|
codSubgrupo Int? @map("cod_subgrupo")
|
||||||
|
codVendedor Int? @map("cod_vendedor")
|
||||||
|
descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
|
||||||
|
dataInicio DateTime @db.Date @map("data_inicio")
|
||||||
|
dataFim DateTime @db.Date @map("data_fim")
|
||||||
|
ativa Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
createdBy String @map("created_by")
|
||||||
|
|
||||||
|
@@index([idEmpresa])
|
||||||
|
@@index([idEmpresa, dataFim])
|
||||||
|
@@map("regras_desconto")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Oportunidade (Funil de Vendas) ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente)
|
||||||
|
// ou ser um prospect livre (nome_prospect / empresa_prospect).
|
||||||
|
// etapa: lead | proposta | negociacao | ganho | perdido
|
||||||
|
|
||||||
|
model Oportunidade {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
codVendedor Int @map("cod_vendedor")
|
||||||
|
idCliente Int? @map("id_cliente")
|
||||||
|
nomeProspect String? @map("nome_prospect") @db.VarChar(200)
|
||||||
|
empresaProspect String? @map("empresa_prospect") @db.VarChar(200)
|
||||||
|
titulo String @db.VarChar(200)
|
||||||
|
etapa String @default("lead") @db.VarChar(20)
|
||||||
|
valorEstimado Decimal? @db.Decimal(15, 2) @map("valor_estimado")
|
||||||
|
observacoes String?
|
||||||
|
idPedido String? @db.Uuid @map("id_pedido")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([idEmpresa, codVendedor])
|
||||||
|
@@index([etapa])
|
||||||
|
@@map("oportunidades")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chamados SAC ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Chamado aberto pelo representante vinculado a um pedido SAR.
|
||||||
|
// tipo: avaria | quantidade_divergente | produto_errado | prazo_entrega | outro
|
||||||
|
// status: aberto | em_analise | aguardando_rep | resolvido | cancelado
|
||||||
|
// resolucao: reposicao | desconto | cancelamento_item | sem_acao | outro
|
||||||
|
|
||||||
|
model Chamado {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
codVendedor Int @map("cod_vendedor")
|
||||||
|
idPedido String? @db.Uuid @map("id_pedido")
|
||||||
|
numPedSar Int? @map("num_ped_sar")
|
||||||
|
numPedErp Int? @map("num_ped_erp")
|
||||||
|
idCliente Int @map("id_cliente")
|
||||||
|
nomeCliente String? @db.VarChar(200) @map("nome_cliente")
|
||||||
|
tipo String @db.VarChar(50)
|
||||||
|
assunto String @db.VarChar(200)
|
||||||
|
descricao String
|
||||||
|
status String @default("aberto") @db.VarChar(30)
|
||||||
|
resolucao String? @db.VarChar(50)
|
||||||
|
notaResolucao String? @map("nota_resolucao")
|
||||||
|
itensAfetados Json? @map("itens_afetados")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
mensagens ChamadoMensagem[]
|
||||||
|
|
||||||
|
@@index([idEmpresa, codVendedor])
|
||||||
|
@@index([idEmpresa, status])
|
||||||
|
@@map("chamados")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ChamadoMensagem {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idChamado Int @map("id_chamado")
|
||||||
|
autor String @db.VarChar(10)
|
||||||
|
texto String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
chamado Chamado @relation(fields: [idChamado], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([idChamado])
|
||||||
|
@@map("chamado_mensagens")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
|
||||||
|
// codVendedor desnormalizado do JWT para filtrar destinatários.
|
||||||
|
|
||||||
|
model PushSubscription {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
codVendedor Int? @map("cod_vendedor")
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
role String
|
||||||
|
endpoint String @unique
|
||||||
|
p256dh String
|
||||||
|
auth String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([idEmpresa])
|
||||||
|
@@index([codVendedor])
|
||||||
|
@@map("push_subscription")
|
||||||
|
}
|
||||||
@@ -1,28 +1,50 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
import { APP_FILTER, APP_GUARD, APP_PIPE } from '@nestjs/core';
|
||||||
import { ZodValidationPipe } from 'nestjs-zod';
|
import { ZodValidationPipe } from 'nestjs-zod';
|
||||||
import { EnvModule } from './config/env.module';
|
import { EnvModule } from './config/env.module';
|
||||||
import { LoggerModule } from './logger/logger.module';
|
import { LoggerModule } from './logger/logger.module';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module';
|
import { WorkspaceModule } from './workspace/workspace.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { PingModule } from './ping/ping.module';
|
import { PingModule } from './ping/ping.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
|
import { ClientsModule } from './clients/clients.module';
|
||||||
|
import { OrdersModule } from './orders/orders.module';
|
||||||
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
|
import { ReportsModule } from './reports/reports.module';
|
||||||
|
import { PoliticasModule } from './politicas/politicas.module';
|
||||||
|
import { EquipeModule } from './equipe/equipe.module';
|
||||||
|
import { FunilModule } from './funil/funil.module';
|
||||||
|
import { SacModule } from './sac/sac.module';
|
||||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
// Ordem importa: Env primeiro (fail-fast), depois Logger, CLS, módulos de domínio.
|
// Ordem: Env primeiro (fail-fast), depois Logger, CLS, Auth, módulos de domínio.
|
||||||
EnvModule,
|
EnvModule,
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
|
AuthModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
PingModule,
|
PingModule,
|
||||||
|
ClientsModule,
|
||||||
|
OrdersModule,
|
||||||
|
CatalogModule,
|
||||||
|
DashboardModule,
|
||||||
|
NotificationsModule,
|
||||||
|
ReportsModule,
|
||||||
|
PoliticasModule,
|
||||||
|
EquipeModule,
|
||||||
|
FunilModule,
|
||||||
|
SacModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Pipe global: nestjs-zod converte ZodSchema (via createZodDto) em validação automática.
|
|
||||||
// CODING-RULES §06: schema é o contrato; DTO é a classe que o expõe.
|
|
||||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||||
// Filter global: RFC 9457. Zod → 422.
|
|
||||||
{ provide: APP_FILTER, useClass: ProblemDetailsFilter },
|
{ provide: APP_FILTER, useClass: ProblemDetailsFilter },
|
||||||
|
// Guard global — exige JWT em todas as rotas exceto as com @Public().
|
||||||
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
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 {}
|
||||||
49
apps/api/src/app/auth/dev-auth.controller.ts
Normal file
49
apps/api/src/app/auth/dev-auth.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Body, Controller, HttpCode, HttpStatus, NotFoundException, Post } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SignJWT } from 'jose';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import { DevTokenRequestSchema, type AuthTokenResponse } from '@sar/api-interface';
|
||||||
|
import type { Env } from '../config/env.schema';
|
||||||
|
import { Public } from './public.decorator';
|
||||||
|
|
||||||
|
class DevTokenRequestDto extends createZodDto(DevTokenRequestSchema) {}
|
||||||
|
|
||||||
|
// Dev-only stub — emite JWT HS256 para smoke tests locais.
|
||||||
|
// CODING-RULES PGD-SEC-002: retorna 404 em produção.
|
||||||
|
// CODING-RULES PGD-AUTHZ-002: id_empresa vem do body aqui APENAS porque
|
||||||
|
// este endpoint É o gerador do token — nenhum outro handler pode fazer isso.
|
||||||
|
// ADR 0006 revogado: workspaceId → idEmpresa (Int da empresa no ERP)
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Controller({ path: 'auth/dev' })
|
||||||
|
export class DevAuthController {
|
||||||
|
private readonly secret: Uint8Array;
|
||||||
|
private readonly expiresIn: number;
|
||||||
|
private readonly isProd: boolean;
|
||||||
|
private readonly devEmpresaId: number;
|
||||||
|
|
||||||
|
constructor(config: ConfigService<Env, true>) {
|
||||||
|
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
|
||||||
|
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
|
||||||
|
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
|
||||||
|
this.devEmpresaId = config.get('DEV_EMPRESA_ID', { infer: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('token')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async token(@Body() dto: DevTokenRequestDto): Promise<AuthTokenResponse> {
|
||||||
|
if (this.isProd) throw new NotFoundException();
|
||||||
|
|
||||||
|
const accessToken = await new SignJWT({
|
||||||
|
id_empresa: dto.idEmpresa ?? this.devEmpresaId,
|
||||||
|
role: dto.role,
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setSubject(dto.userId)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${this.expiresIn}s`)
|
||||||
|
.sign(this.secret);
|
||||||
|
|
||||||
|
return { accessToken, tokenType: 'Bearer', expiresIn: this.expiresIn };
|
||||||
|
}
|
||||||
|
}
|
||||||
93
apps/api/src/app/auth/jwt-auth.guard.ts
Normal file
93
apps/api/src/app/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import type { Env } from '../config/env.schema';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { WorkspacePrismaPool } from '../workspace/workspace-prisma-pool.service';
|
||||||
|
import type { JwtPayload } from './jwt.types';
|
||||||
|
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||||
|
|
||||||
|
// Guard global (APP_GUARD). Valida Bearer HS256 e atualiza CLS com idEmpresa real.
|
||||||
|
// CODING-RULES PGD-AUTHZ-002: idEmpresa sempre do JWT, nunca de body/param.
|
||||||
|
// Ordem NestJS: middleware CLS (idEmpresa default) → este guard (idEmpresa real).
|
||||||
|
// ADR 0006 revogado: workspace_id → id_empresa; URL inclui ?schema=sar
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
private readonly secret: Uint8Array;
|
||||||
|
private readonly isProd: boolean;
|
||||||
|
private readonly devRepCode: string;
|
||||||
|
private readonly devEmpresaId: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
|
private readonly pool: WorkspacePrismaPool,
|
||||||
|
config: ConfigService<Env, true>,
|
||||||
|
) {
|
||||||
|
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
|
||||||
|
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
|
||||||
|
this.devRepCode = String(config.get('DEV_REP_CODE', { infer: true }));
|
||||||
|
this.devEmpresaId = config.get('DEV_EMPRESA_ID', { infer: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (this.isPublic(context)) return true;
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
|
const token = this.extractBearer(req);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('token ausente');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify<JwtPayload>(token, this.secret, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
});
|
||||||
|
|
||||||
|
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
|
||||||
|
|
||||||
|
// Em dev: usa sub/id_empresa do próprio JWT (emitido pelo /auth/dev/token,
|
||||||
|
// que permite logar como Pavei/Sidnei/Lucas) e cai para DEV_REP_CODE /
|
||||||
|
// DEV_EMPRESA_ID apenas se o token não trouxer os campos.
|
||||||
|
// Em prod: usa os valores reais do JWT do master-login.
|
||||||
|
const idEmpresa = payload.id_empresa ?? (this.isProd ? undefined : this.devEmpresaId);
|
||||||
|
const userId = payload.sub ?? (this.isProd ? undefined : this.devRepCode);
|
||||||
|
if (idEmpresa == null || userId == null) {
|
||||||
|
throw new UnauthorizedException('token sem sub/id_empresa');
|
||||||
|
}
|
||||||
|
this.cls.set('idEmpresa', idEmpresa);
|
||||||
|
this.cls.set('userId', userId);
|
||||||
|
this.cls.set('role', payload.role);
|
||||||
|
|
||||||
|
const baseUrl = process.env['DATABASE_URL'];
|
||||||
|
if (!baseUrl) throw new Error('DATABASE_URL não configurada');
|
||||||
|
this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, baseUrl));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('token inválido ou expirado');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPublic(ctx: ExecutionContext): boolean {
|
||||||
|
return (
|
||||||
|
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
ctx.getHandler(),
|
||||||
|
ctx.getClass(),
|
||||||
|
]) === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractBearer(req: Request): string | undefined {
|
||||||
|
const auth = req.headers['authorization'];
|
||||||
|
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
||||||
|
return auth.slice(7);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
55
apps/api/src/app/catalog/catalog.controller.ts
Normal file
55
apps/api/src/app/catalog/catalog.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
ProdutoListQuerySchema,
|
||||||
|
type EmpresaInfo,
|
||||||
|
type FormaPagamento,
|
||||||
|
type Municipio,
|
||||||
|
type Pauta,
|
||||||
|
type ProdutoDetail,
|
||||||
|
type ProdutoListQuery,
|
||||||
|
type ProdutoListResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { CatalogService } from './catalog.service';
|
||||||
|
|
||||||
|
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
|
||||||
|
|
||||||
|
// ADR 0006 revogado: UUID → Int para ID de produto. Sync removido (ERP direto via view).
|
||||||
|
|
||||||
|
@Controller({ path: 'catalog' })
|
||||||
|
export class CatalogController {
|
||||||
|
constructor(private readonly catalog: CatalogService) {}
|
||||||
|
|
||||||
|
@Get('municipios')
|
||||||
|
municipios(@Query('q') q?: string): Promise<Municipio[]> {
|
||||||
|
return this.catalog.municipios(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('pautas')
|
||||||
|
pautas(): Promise<Pauta[]> {
|
||||||
|
return this.catalog.pautas();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('payment-methods')
|
||||||
|
formasPagamento(): Promise<FormaPagamento[]> {
|
||||||
|
return this.catalog.formasPagamento();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('company')
|
||||||
|
company(): Promise<EmpresaInfo> {
|
||||||
|
return this.catalog.company();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
|
||||||
|
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;
|
||||||
|
return this.catalog.list(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(@Param('id', ParseIntPipe) id: number): Promise<ProdutoDetail> {
|
||||||
|
const product = await this.catalog.findOne(id);
|
||||||
|
if (!product) throw new NotFoundException(`Produto ${id} não encontrado`);
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {}
|
||||||
403
apps/api/src/app/catalog/catalog.service.ts
Normal file
403
apps/api/src/app/catalog/catalog.service.ts
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type {
|
||||||
|
EmpresaInfo,
|
||||||
|
FormaPagamento,
|
||||||
|
Municipio,
|
||||||
|
Pauta,
|
||||||
|
ProdutoDetail,
|
||||||
|
ProdutoListQuery,
|
||||||
|
ProdutoListResponse,
|
||||||
|
ProdutoSummary,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
|
// ADR 0006 revogado: produtos lidos diretamente de vw_produtos (ERP) + vw_estoque.
|
||||||
|
// Sem sync — dados sempre frescos da view do ERP.
|
||||||
|
|
||||||
|
function escSql(s: string): string {
|
||||||
|
return s.replace(/'/g, "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Produtos, pautas e estoque são por-empresa e vivem na MATRIZ. O ERP usa códigos
|
||||||
|
// de empresa > 9000 para origem de pedido (ex.: 9001), mas o cadastro fica na
|
||||||
|
// matriz correspondente (9001 → 1), espelhando o CASE de vw_pedidos_erp.
|
||||||
|
// Pedidos continuam usando idEmpresa cru; só o catálogo normaliza.
|
||||||
|
function matrizEmpresa(idEmpresa: number): number {
|
||||||
|
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProdutoRow {
|
||||||
|
id_erp: number;
|
||||||
|
codigo: string;
|
||||||
|
descricao: string;
|
||||||
|
unidade: string | null;
|
||||||
|
vl_preco1: string;
|
||||||
|
cod_grupo: number | null;
|
||||||
|
grupo: string | null;
|
||||||
|
cod_subgrupo: number | null;
|
||||||
|
subgrupo: string | null;
|
||||||
|
marca: string | null;
|
||||||
|
ativo: number;
|
||||||
|
qtd_estoque: string | null;
|
||||||
|
lista_pauta: number | null;
|
||||||
|
referencia: string | null;
|
||||||
|
descr_det: string | null;
|
||||||
|
vl_preco2: string | null;
|
||||||
|
vl_preco3: string | null;
|
||||||
|
aliq_ipi: string | null;
|
||||||
|
peso_liquido: string | null;
|
||||||
|
qtd_volume: string | null;
|
||||||
|
lote_mul_venda: number | null;
|
||||||
|
preco_promocional: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogService {
|
||||||
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
// Dados legais da empresa matriz que fatura o pedido (cabeçalho do PDF).
|
||||||
|
async company(): Promise<EmpresaInfo> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id_empresa: number;
|
||||||
|
razao_social: string | null;
|
||||||
|
nome_fantasia: string | null;
|
||||||
|
cnpj: string | null;
|
||||||
|
inscr_estadual: string | null;
|
||||||
|
endereco: string | null;
|
||||||
|
numero: string | null;
|
||||||
|
complemento: string | null;
|
||||||
|
bairro: string | null;
|
||||||
|
cidade: string | null;
|
||||||
|
uf: string | null;
|
||||||
|
cep: string | null;
|
||||||
|
telefone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT e.id_empresa,
|
||||||
|
TRIM(e.razao_social) AS razao_social,
|
||||||
|
TRIM(e.nome) AS nome_fantasia,
|
||||||
|
TRIM(e.cnpj) AS cnpj,
|
||||||
|
TRIM(e.inscr_estadual) AS inscr_estadual,
|
||||||
|
TRIM(e.endereco) AS endereco,
|
||||||
|
e.num_endereco::text AS numero,
|
||||||
|
e.complemento,
|
||||||
|
TRIM(e.bairro) AS bairro,
|
||||||
|
TRIM(m.nome) AS cidade,
|
||||||
|
TRIM(e.uf) AS uf,
|
||||||
|
e.cep,
|
||||||
|
e.telefone,
|
||||||
|
TRIM(e.email) AS email
|
||||||
|
FROM sar.vw_empresas e
|
||||||
|
LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio
|
||||||
|
WHERE e.id_empresa = ${idEmpresa}
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
const r = rows[0];
|
||||||
|
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`);
|
||||||
|
|
||||||
|
const clean = (v: string | null) => {
|
||||||
|
const t = (v ?? '').trim();
|
||||||
|
return t === '' ? null : t;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
idEmpresa: Number(r.id_empresa),
|
||||||
|
razaoSocial: clean(r.razao_social) ?? clean(r.nome_fantasia) ?? `Empresa ${r.id_empresa}`,
|
||||||
|
nomeFantasia: clean(r.nome_fantasia),
|
||||||
|
cnpj: clean(r.cnpj),
|
||||||
|
inscricaoEstadual: clean(r.inscr_estadual),
|
||||||
|
endereco: clean(r.endereco),
|
||||||
|
numero: clean(r.numero),
|
||||||
|
complemento: clean(r.complemento),
|
||||||
|
bairro: clean(r.bairro),
|
||||||
|
cidade: clean(r.cidade),
|
||||||
|
uf: clean(r.uf),
|
||||||
|
cep: clean(r.cep),
|
||||||
|
telefone: clean(r.telefone),
|
||||||
|
email: clean(r.email),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async municipios(q?: string): Promise<Municipio[]> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id_municipio: number;
|
||||||
|
nome: string;
|
||||||
|
uf: string;
|
||||||
|
codigo_ibge: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFilter = q ? `WHERE UPPER(nome) LIKE UPPER('%${escSql(q)}%')` : '';
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT id_municipio, TRIM(nome) AS nome, uf::text AS uf, codigo_ibge
|
||||||
|
FROM sar.vw_municipios
|
||||||
|
${searchFilter}
|
||||||
|
ORDER BY nome
|
||||||
|
LIMIT 100
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
idMunicipio: Number(r.id_municipio),
|
||||||
|
nome: r.nome,
|
||||||
|
uf: r.uf,
|
||||||
|
codigoIbge: r.codigo_ibge !== null ? Number(r.codigo_ibge) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async pautas(): Promise<Pauta[]> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
|
interface PautaRow {
|
||||||
|
id_pauta: number;
|
||||||
|
codigo: number;
|
||||||
|
descricao: string;
|
||||||
|
}
|
||||||
|
const rows = await prisma.$queryRawUnsafe<PautaRow[]>(`
|
||||||
|
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
|
||||||
|
FROM vw_pautas pa
|
||||||
|
JOIN vw_representantes r ON pa.codigo IN (
|
||||||
|
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||||
|
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||||
|
)
|
||||||
|
WHERE pa.id_empresa = ${idEmpresa}
|
||||||
|
AND pa.ativo = 1
|
||||||
|
AND r.codigo = ${codVendedor}
|
||||||
|
ORDER BY pa.codigo
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
idPauta: Number(r.id_pauta),
|
||||||
|
codigo: Number(r.codigo),
|
||||||
|
descricao: r.descricao,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ProdutoListQuery): Promise<ProdutoListResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
|
const { q, codGrupo, idPauta, page, limit } = query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
|
||||||
|
const searchFilter = q
|
||||||
|
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Com pauta: usa preço específico da pauta. Sem pauta: filtra vl_preco1 > 0.
|
||||||
|
if (idPauta != null) {
|
||||||
|
interface PautaItemRow extends ProdutoRow {
|
||||||
|
preco_pauta: string;
|
||||||
|
}
|
||||||
|
const [rows, countRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<PautaItemRow[]>(`
|
||||||
|
SELECT
|
||||||
|
p.id_erp, p.codigo, p.descricao, p.unidade,
|
||||||
|
pp.preco1::text AS preco_pauta,
|
||||||
|
p.cod_grupo, p.grupo, p.cod_subgrupo, p.subgrupo, p.marca,
|
||||||
|
p.ativo, e.qtd_estoque::text, p.lista_pauta,
|
||||||
|
p.referencia, p.descr_det, p.vl_preco2::text, p.vl_preco3::text,
|
||||||
|
p.aliq_ipi::text, p.peso_liquido::text, p.qtd_volume::text,
|
||||||
|
p.lote_mul_venda, p.preco_promocional::text
|
||||||
|
FROM vw_pauta_produtos pp
|
||||||
|
JOIN vw_produtos p ON p.id_erp = pp.id_produto AND p.id_empresa = ${idEmpresa}
|
||||||
|
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
|
||||||
|
WHERE pp.id_pauta = ${idPauta}
|
||||||
|
AND p.ativo = 1
|
||||||
|
AND pp.preco1 > 0
|
||||||
|
${grupoFilter}
|
||||||
|
${searchFilter}
|
||||||
|
ORDER BY p.descricao
|
||||||
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<[{ count: string }]>(`
|
||||||
|
SELECT COUNT(*)::text AS count
|
||||||
|
FROM vw_pauta_produtos pp
|
||||||
|
JOIN vw_produtos p ON p.id_erp = pp.id_produto AND p.id_empresa = ${idEmpresa}
|
||||||
|
WHERE pp.id_pauta = ${idPauta} AND p.ativo = 1 AND pp.preco1 > 0
|
||||||
|
${grupoFilter} ${searchFilter}
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
const total = parseInt(countRows[0]?.count ?? '0', 10);
|
||||||
|
return {
|
||||||
|
data: rows.map((p) => this.mapRow(p, p.preco_pauta)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sem pauta: produtos com preço base preenchido
|
||||||
|
const [rows, countRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<ProdutoRow[]>(`
|
||||||
|
SELECT
|
||||||
|
p.id_erp, p.codigo, p.descricao, p.unidade,
|
||||||
|
p.vl_preco1::text,
|
||||||
|
p.cod_grupo, p.grupo, p.cod_subgrupo, p.subgrupo, p.marca,
|
||||||
|
p.ativo, e.qtd_estoque::text, p.lista_pauta,
|
||||||
|
p.referencia, p.descr_det, p.vl_preco2::text, p.vl_preco3::text,
|
||||||
|
p.aliq_ipi::text, p.peso_liquido::text, p.qtd_volume::text,
|
||||||
|
p.lote_mul_venda, p.preco_promocional::text
|
||||||
|
FROM vw_produtos p
|
||||||
|
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
|
||||||
|
WHERE p.id_empresa = ${idEmpresa} AND p.ativo = 1 AND p.vl_preco1 > 0
|
||||||
|
${grupoFilter} ${searchFilter}
|
||||||
|
ORDER BY p.descricao
|
||||||
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<[{ count: string }]>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM vw_produtos p
|
||||||
|
WHERE p.id_empresa = ${idEmpresa} AND p.ativo = 1 AND p.vl_preco1 > 0
|
||||||
|
${grupoFilter} ${searchFilter}
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
const total = parseInt(countRows[0]?.count ?? '0', 10);
|
||||||
|
return { data: rows.map((p) => this.mapRow(p, p.vl_preco1)), total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRow(p: ProdutoRow, preco: string): ProdutoSummary {
|
||||||
|
return {
|
||||||
|
idErp: Number(p.id_erp),
|
||||||
|
codigo: (p.codigo ?? '').trim(),
|
||||||
|
descricao: (p.descricao ?? '').trim(),
|
||||||
|
unidade: p.unidade,
|
||||||
|
vlPreco1: preco ?? '0',
|
||||||
|
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
|
||||||
|
grupo: p.grupo ? p.grupo.trim() : null,
|
||||||
|
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
|
||||||
|
subgrupo: p.subgrupo ? p.subgrupo.trim() : null,
|
||||||
|
marca: p.marca ? p.marca.trim() : null,
|
||||||
|
ativo: Number(p.ativo),
|
||||||
|
qtdEstoque: p.qtd_estoque,
|
||||||
|
listaParauta: p.lista_pauta !== null ? Number(p.lista_pauta) : null,
|
||||||
|
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(idErp: number): Promise<ProdutoDetail | null> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
|
interface PautaPrecoRow {
|
||||||
|
id_pauta: number;
|
||||||
|
codigo: number;
|
||||||
|
descricao: string;
|
||||||
|
preco: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows, pautaRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<ProdutoRow[]>(`
|
||||||
|
SELECT
|
||||||
|
p.id_erp,
|
||||||
|
p.codigo,
|
||||||
|
p.descricao,
|
||||||
|
p.unidade,
|
||||||
|
p.vl_preco1::text,
|
||||||
|
p.cod_grupo,
|
||||||
|
p.grupo,
|
||||||
|
p.cod_subgrupo,
|
||||||
|
p.subgrupo,
|
||||||
|
p.marca,
|
||||||
|
p.ativo,
|
||||||
|
e.qtd_estoque::text,
|
||||||
|
p.lista_pauta,
|
||||||
|
p.referencia,
|
||||||
|
p.descr_det,
|
||||||
|
p.vl_preco2::text,
|
||||||
|
p.vl_preco3::text,
|
||||||
|
p.aliq_ipi::text,
|
||||||
|
p.peso_liquido::text,
|
||||||
|
p.qtd_volume::text,
|
||||||
|
p.lote_mul_venda,
|
||||||
|
p.preco_promocional::text
|
||||||
|
FROM vw_produtos p
|
||||||
|
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
|
||||||
|
WHERE p.id_erp = ${idErp} AND p.ativo = 1
|
||||||
|
LIMIT 1
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<PautaPrecoRow[]>(`
|
||||||
|
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao, pp.preco1::text AS preco
|
||||||
|
FROM vw_pauta_produtos pp
|
||||||
|
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
|
||||||
|
AND pa.id_empresa = ${idEmpresa}
|
||||||
|
AND pa.ativo = 1
|
||||||
|
JOIN vw_representantes r ON pa.codigo IN (
|
||||||
|
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||||
|
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||||
|
)
|
||||||
|
WHERE pp.id_produto = ${idErp}
|
||||||
|
AND r.codigo = ${codVendedor}
|
||||||
|
AND pp.preco1 > 0
|
||||||
|
ORDER BY pa.codigo
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const p = rows[0];
|
||||||
|
if (!p) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...this.mapRow(p, p.vl_preco1),
|
||||||
|
referencia: p.referencia,
|
||||||
|
descricaoDetalhada: p.descr_det,
|
||||||
|
vlPreco2: p.vl_preco2,
|
||||||
|
vlPreco3: p.vl_preco3,
|
||||||
|
aliqIpi: p.aliq_ipi,
|
||||||
|
pesoLiquido: p.peso_liquido,
|
||||||
|
qtdVolume: p.qtd_volume,
|
||||||
|
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
|
||||||
|
precoComIpi: null,
|
||||||
|
precoPromocional: p.preco_promocional,
|
||||||
|
pautaPrecos: pautaRows.map((r) => ({
|
||||||
|
idPauta: Number(r.id_pauta),
|
||||||
|
codigo: Number(r.codigo),
|
||||||
|
descricao: r.descricao,
|
||||||
|
preco: r.preco,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async formasPagamento(): Promise<FormaPagamento[]> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
codigo: number;
|
||||||
|
descricao: string;
|
||||||
|
num_parcelas: number | null;
|
||||||
|
tx_acrescimo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT codigo, TRIM(descricao) AS descricao, num_parcelas, tx_acrescimo::text
|
||||||
|
FROM sar.vw_formas_pagamento
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND ativa = 1
|
||||||
|
AND integrar_sar = 1
|
||||||
|
ORDER BY codigo
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
codigo: Number(r.codigo),
|
||||||
|
descricao: r.descricao,
|
||||||
|
numParcelas: r.num_parcelas !== null ? Number(r.num_parcelas) : null,
|
||||||
|
txAcrescimo: r.tx_acrescimo ?? '0',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
102
apps/api/src/app/clients/clients.controller.ts
Normal file
102
apps/api/src/app/clients/clients.controller.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
ClientListQuerySchema,
|
||||||
|
CreateClientNovoSchema,
|
||||||
|
CreateContatoSchema,
|
||||||
|
type ClientDetail,
|
||||||
|
type ClientListQuery,
|
||||||
|
type ClientListResponse,
|
||||||
|
type ClientNovoResult,
|
||||||
|
type Contato,
|
||||||
|
type ContatoResult,
|
||||||
|
type CtrSummary,
|
||||||
|
type CreateClientNovo,
|
||||||
|
type CreateContato,
|
||||||
|
type CtrTitulo,
|
||||||
|
type NotaFiscal,
|
||||||
|
type PedidoSummary,
|
||||||
|
type TopProduto,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { ClientsService } from './clients.service';
|
||||||
|
|
||||||
|
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
|
||||||
|
class CreateClientNovoDto extends createZodDto(CreateClientNovoSchema) {}
|
||||||
|
class CreateContatoDto extends createZodDto(CreateContatoSchema) {}
|
||||||
|
|
||||||
|
@Controller({ path: 'clients' })
|
||||||
|
export class ClientsController {
|
||||||
|
constructor(private readonly clients: ClientsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: ClientListQueryDto): Promise<ClientListResponse> {
|
||||||
|
const parsed = ClientListQuerySchema.parse(query) as ClientListQuery;
|
||||||
|
return this.clients.list(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('novo')
|
||||||
|
createNovo(@Body() body: CreateClientNovoDto): Promise<ClientNovoResult> {
|
||||||
|
const parsed = CreateClientNovoSchema.parse(body) as CreateClientNovo;
|
||||||
|
return this.clients.createNovo(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/notas')
|
||||||
|
listNotasFiscais(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
): Promise<NotaFiscal[]> {
|
||||||
|
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
|
||||||
|
return this.clients.listNotasFiscais(id, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/top-produtos')
|
||||||
|
listTopProdutos(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
): Promise<TopProduto[]> {
|
||||||
|
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
|
||||||
|
return this.clients.listTopProdutos(id, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/orders-history')
|
||||||
|
listOrdersHistory(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
): Promise<PedidoSummary[]> {
|
||||||
|
const n = limit ? Math.min(parseInt(limit, 10) || 50, 200) : 50;
|
||||||
|
return this.clients.listOrdersHistory(id, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/ctr')
|
||||||
|
getCtrSummary(@Param('id', ParseIntPipe) id: number): Promise<CtrSummary> {
|
||||||
|
return this.clients.getCtrSummary(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/ctr-list')
|
||||||
|
listCtrTitulos(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
): Promise<CtrTitulo[]> {
|
||||||
|
const n = limit ? Math.min(parseInt(limit, 10) || 60, 200) : 60;
|
||||||
|
return this.clients.listCtrTitulos(id, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/contacts')
|
||||||
|
listContacts(@Param('id', ParseIntPipe) id: number): Promise<Contato[]> {
|
||||||
|
return this.clients.listContacts(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/contacts')
|
||||||
|
createContato(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() body: CreateContatoDto,
|
||||||
|
): Promise<ContatoResult> {
|
||||||
|
const parsed = CreateContatoSchema.parse(body) as CreateContato;
|
||||||
|
return this.clients.createContato(id, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: number): Promise<ClientDetail> {
|
||||||
|
return this.clients.findOne(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {}
|
||||||
734
apps/api/src/app/clients/clients.service.ts
Normal file
734
apps/api/src/app/clients/clients.service.ts
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type {
|
||||||
|
ActivityStatus,
|
||||||
|
ClientDetail,
|
||||||
|
ClientListQuery,
|
||||||
|
ClientListResponse,
|
||||||
|
ClientNovoResult,
|
||||||
|
ClientSummary,
|
||||||
|
Contato,
|
||||||
|
ContatoResult,
|
||||||
|
CtrSummary,
|
||||||
|
CtrTitulo,
|
||||||
|
CreateClientNovo,
|
||||||
|
CreateContato,
|
||||||
|
NotaFiscal,
|
||||||
|
PedidoSummary,
|
||||||
|
TopProduto,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||||
|
|
||||||
|
// Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
|
||||||
|
const ALERT_DAYS = 30;
|
||||||
|
const INACTIVE_DAYS = 60;
|
||||||
|
|
||||||
|
// Usado apenas por findOne (já tem dt_ultima_compra calculado pelo SQL)
|
||||||
|
function activityStatus(dtUltimaCompra: Date | null): ActivityStatus {
|
||||||
|
if (!dtUltimaCompra) return 'inactive';
|
||||||
|
const days = Math.floor((Date.now() - dtUltimaCompra.getTime()) / 86_400_000);
|
||||||
|
if (days >= INACTIVE_DAYS) return 'inactive';
|
||||||
|
if (days >= ALERT_DAYS) return 'alert';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escSql(s: string): string {
|
||||||
|
return s.replace(/'/g, "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Financeiro (CTR) e NF vivem na empresa MATRIZ. O ERP usa códigos > 9000 para
|
||||||
|
// origem de pedido (ex.: 9001 → matriz 1), espelhando catalog/dashboard/equipe.
|
||||||
|
function matrizEmpresa(idEmpresa: number): number {
|
||||||
|
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row bruta do $queryRawUnsafe
|
||||||
|
interface ClientRow {
|
||||||
|
id_cliente: number;
|
||||||
|
id_empresa: number;
|
||||||
|
nome: string;
|
||||||
|
razao: string | null;
|
||||||
|
cgcpf: string | null;
|
||||||
|
email: string | null;
|
||||||
|
telefone: string | null;
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome_vendedor: string | null;
|
||||||
|
limite_credito: string | null;
|
||||||
|
dt_ultima_compra: Date | null;
|
||||||
|
ativo: number;
|
||||||
|
pessoa: number | null;
|
||||||
|
inscricao_estadual: string | null;
|
||||||
|
endereco: string | null;
|
||||||
|
num_endereco: string | null;
|
||||||
|
bairro: string | null;
|
||||||
|
cep: string | null;
|
||||||
|
ddd: string | null;
|
||||||
|
obs: string | null;
|
||||||
|
cod_pauta: number | null;
|
||||||
|
dt_cadastro: string | null;
|
||||||
|
dt_atual: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL compartilhado: dois subqueries que calculam a data do último pedido
|
||||||
|
// considerando TANTO pedidos ERP (vw_pedidos_erp) QUANTO pedidos SAR (tabela pedidos).
|
||||||
|
// vw_pedidos_erp: situa SIG 5=Cancelado (excluir); pedidos SAR: situa 3=Cancelado (excluir).
|
||||||
|
// Clientes são cadastro GLOBAL (sem vínculo de id_empresa). A "última compra",
|
||||||
|
// porém, é escopada à empresa atual: filtramos os pedidos por idEmpresa e juntamos
|
||||||
|
// apenas por id_cliente.
|
||||||
|
function pedidosJoins(idEmpresa: number): string {
|
||||||
|
return `
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT id_cliente, MAX(dt_pedido) AS dt_max
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
|
||||||
|
GROUP BY id_cliente
|
||||||
|
) erp_ped ON erp_ped.id_cliente = c.id_cliente
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT id_cliente, MAX(dt_pedido) AS dt_max
|
||||||
|
FROM pedidos
|
||||||
|
WHERE situa != 3 AND id_empresa = ${idEmpresa}
|
||||||
|
GROUP BY id_cliente
|
||||||
|
) sar_ped ON sar_ped.id_cliente = c.id_cliente
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subquery escalar para o nome do representante (cadastro global, sem id_empresa).
|
||||||
|
// NÃO usar JOIN: vw_representantes tem códigos duplicados, o que multiplicaria as
|
||||||
|
// linhas de cliente e quebraria contagem/paginação. LIMIT 1 garante 1 nome.
|
||||||
|
const NOME_VENDEDOR_SUBQ = `
|
||||||
|
(SELECT r.nome FROM vw_representantes r
|
||||||
|
WHERE r.codigo = c.cod_vendedor
|
||||||
|
LIMIT 1) AS nome_vendedor`;
|
||||||
|
|
||||||
|
// Expressão SQL que calcula o activity_status a partir das datas dos dois joins.
|
||||||
|
const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
|
||||||
|
CASE
|
||||||
|
WHEN GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max) IS NULL THEN 'inactive'
|
||||||
|
WHEN (CURRENT_DATE - GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max)::date) >= ${INACTIVE_DAYS} THEN 'inactive'
|
||||||
|
WHEN (CURRENT_DATE - GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max)::date) >= ${ALERT_DAYS} THEN 'alert'
|
||||||
|
ELSE 'active'
|
||||||
|
END
|
||||||
|
`;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClientsService {
|
||||||
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: rep só acessa clientes da própria carteira; supervisor acessa as
|
||||||
|
// carteiras da sua equipe (cod_supervisor); gerente/admin passam direto.
|
||||||
|
// NotFound (não Forbidden) para não revelar a existência de clientes de terceiros.
|
||||||
|
private async assertClienteDaCarteira(idCliente: number): Promise<void> {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role !== 'rep' && role !== 'supervisor') return;
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||||
|
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||||
|
idCliente,
|
||||||
|
);
|
||||||
|
const dono = rows[0] ? Number(rows[0].cod_vendedor) : null;
|
||||||
|
const permitidos =
|
||||||
|
role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : [codVendedor];
|
||||||
|
if (dono == null || !permitidos.includes(dono)) {
|
||||||
|
throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ClientListQuery): Promise<ClientListResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
|
const { q, status, page, limit } = query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Rep vê apenas sua carteira; supervisor vê as carteiras da equipe
|
||||||
|
const vendedorFilter =
|
||||||
|
role === 'rep'
|
||||||
|
? `AND c.cod_vendedor = ${codVendedor}`
|
||||||
|
: role === 'supervisor'
|
||||||
|
? `AND c.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
|
||||||
|
: '';
|
||||||
|
const searchFilter = q
|
||||||
|
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Filtro de status calculado em SQL — evita paginação quebrada do filtro pós-SQL
|
||||||
|
const statusFilter = status ? `AND ${ACTIVITY_CASE()} = '${status}'` : '';
|
||||||
|
|
||||||
|
// Clientes globais: sem filtro de id_empresa. Rep continua escopado por cod_vendedor.
|
||||||
|
const baseWhere = `
|
||||||
|
WHERE c.ativo = 1
|
||||||
|
${vendedorFilter}
|
||||||
|
${searchFilter}
|
||||||
|
${statusFilter}
|
||||||
|
`;
|
||||||
|
const joins = pedidosJoins(idEmpresa);
|
||||||
|
|
||||||
|
const [rows, totalRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<ClientRow[]>(`
|
||||||
|
SELECT
|
||||||
|
c.id_cliente,
|
||||||
|
c.id_empresa,
|
||||||
|
c.nome,
|
||||||
|
c.razao,
|
||||||
|
c.cgcpf,
|
||||||
|
c.email,
|
||||||
|
c.telefone,
|
||||||
|
c.cod_vendedor,
|
||||||
|
${NOME_VENDEDOR_SUBQ},
|
||||||
|
c.limite_credito::text,
|
||||||
|
c.ativo,
|
||||||
|
c.pessoa,
|
||||||
|
c.inscricao_estadual,
|
||||||
|
c.endereco,
|
||||||
|
c.num_endereco,
|
||||||
|
c.bairro,
|
||||||
|
c.cep,
|
||||||
|
c.ddd,
|
||||||
|
c.obs,
|
||||||
|
c.cod_pauta,
|
||||||
|
c.dt_cadastro::text,
|
||||||
|
c.dt_atual::text,
|
||||||
|
GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra
|
||||||
|
FROM vw_clientes c
|
||||||
|
${joins}
|
||||||
|
${baseWhere}
|
||||||
|
ORDER BY c.nome
|
||||||
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<[{ count: string }]>(`
|
||||||
|
SELECT COUNT(*)::text AS count
|
||||||
|
FROM vw_clientes c
|
||||||
|
${joins}
|
||||||
|
${baseWhere}
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(totalRows[0]?.count ?? '0', 10);
|
||||||
|
|
||||||
|
const mapped: ClientSummary[] = rows.map((r) => ({
|
||||||
|
idCliente: Number(r.id_cliente),
|
||||||
|
idEmpresa: Number(r.id_empresa),
|
||||||
|
nome: r.nome,
|
||||||
|
razao: r.razao,
|
||||||
|
cgcpf: r.cgcpf,
|
||||||
|
email: r.email,
|
||||||
|
telefone: r.telefone,
|
||||||
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome_vendedor ?? null,
|
||||||
|
limiteCreditoStr: r.limite_credito,
|
||||||
|
activityStatus: activityStatus(r.dt_ultima_compra),
|
||||||
|
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { data: mapped, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNovo(dto: CreateClientNovo): Promise<ClientNovoResult> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
id_corrent_erp: number | null;
|
||||||
|
sincronizado: boolean;
|
||||||
|
erro_sync: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const esc = (s: string) => s.replace(/'/g, "''");
|
||||||
|
const opt = (v: string | undefined) => (v != null ? `'${esc(v)}'` : 'NULL');
|
||||||
|
const optN = (v: number | undefined) => (v != null ? String(v) : 'NULL');
|
||||||
|
|
||||||
|
// INSERT retorna id — RETURNING lê valores pré-trigger, então
|
||||||
|
// fazemos SELECT separado para capturar o que o trigger gravou
|
||||||
|
const insertRows = await prisma.$queryRawUnsafe<[{ id: string }]>(`
|
||||||
|
INSERT INTO sar.clientes_novos (
|
||||||
|
id_empresa, cod_vendedor, pesso, consfinal,
|
||||||
|
nome, razao, cgcpf, suf_cgcpf, inscr, indicador_ie,
|
||||||
|
endereco, num_endereco, bairro, id_municipio, cep,
|
||||||
|
ddd, telefone, email, limite_credito, cod_formapag, cod_pauta
|
||||||
|
) VALUES (
|
||||||
|
${idEmpresa}, ${codVendedor}, ${dto.pesso}, ${dto.consfinal},
|
||||||
|
'${esc(dto.nome)}', '${esc(dto.razao)}',
|
||||||
|
${opt(dto.cgcpf)}, '${esc(dto.sufCgcpf ?? '')}',
|
||||||
|
'${esc(dto.inscr ?? '')}', ${optN(dto.indicadorIe)},
|
||||||
|
'${esc(dto.endereco ?? '')}', '${esc(dto.numEndereco ?? '')}',
|
||||||
|
'${esc(dto.bairro ?? '')}', ${dto.idMunicipio}, '${esc(dto.cep ?? '')}',
|
||||||
|
'${esc(dto.ddd ?? '')}', '${esc(dto.telefone ?? '')}',
|
||||||
|
'${esc(dto.email ?? '')}', ${dto.limiteCredito ?? 0},
|
||||||
|
${optN(dto.codFormapag)}, ${optN(dto.codPauta)}
|
||||||
|
)
|
||||||
|
RETURNING id::text
|
||||||
|
`);
|
||||||
|
|
||||||
|
const id = insertRows[0]?.id;
|
||||||
|
if (!id) throw new Error('Falha ao inserir cliente');
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT id::text, id_corrent_erp, sincronizado, erro_sync
|
||||||
|
FROM sar.clientes_novos WHERE id = '${id}'
|
||||||
|
`);
|
||||||
|
|
||||||
|
const r = rows[0];
|
||||||
|
if (!r) throw new Error('Falha ao recuperar cliente inserido');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
idCorrentErp: r.id_corrent_erp !== null ? Number(r.id_corrent_erp) : null,
|
||||||
|
sincronizado: r.sincronizado,
|
||||||
|
erroSync: r.erro_sync,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listContacts(idCorrent: number): Promise<Contato[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCorrent);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id_contato: number;
|
||||||
|
id_empresa: number;
|
||||||
|
id_corrent: number;
|
||||||
|
id_chatwoot: number | null;
|
||||||
|
nome: string;
|
||||||
|
empresa: string | null;
|
||||||
|
cargo: string | null;
|
||||||
|
departamento: string | null;
|
||||||
|
dt_aniversario: string | null;
|
||||||
|
telefone: string | null;
|
||||||
|
ramal: string | null;
|
||||||
|
celular: string | null;
|
||||||
|
whatsapp: string | null;
|
||||||
|
email: string | null;
|
||||||
|
ativo: number;
|
||||||
|
anotacoes: string | null;
|
||||||
|
nome_cliente: string | null;
|
||||||
|
razao_cliente: string | null;
|
||||||
|
cod_vendedor: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT id_contato, id_empresa, id_corrent, id_chatwoot,
|
||||||
|
nome, empresa, cargo, departamento,
|
||||||
|
dt_aniversario::text, telefone, ramal, celular, whatsapp, email,
|
||||||
|
ativo, anotacoes, nome_cliente, razao_cliente, cod_vendedor
|
||||||
|
FROM sar.vw_contatos
|
||||||
|
WHERE id_corrent = ${idCorrent} AND ativo = 1
|
||||||
|
ORDER BY nome
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
idContato: Number(r.id_contato),
|
||||||
|
idEmpresa: Number(r.id_empresa),
|
||||||
|
idCorrent: Number(r.id_corrent),
|
||||||
|
idChatwoot: r.id_chatwoot !== null ? Number(r.id_chatwoot) : null,
|
||||||
|
nome: r.nome,
|
||||||
|
empresa: r.empresa,
|
||||||
|
cargo: r.cargo,
|
||||||
|
departamento: r.departamento,
|
||||||
|
dtAniversario: r.dt_aniversario,
|
||||||
|
telefone: r.telefone,
|
||||||
|
ramal: r.ramal,
|
||||||
|
celular: r.celular,
|
||||||
|
whatsapp: r.whatsapp,
|
||||||
|
email: r.email,
|
||||||
|
ativo: Number(r.ativo),
|
||||||
|
anotacoes: r.anotacoes,
|
||||||
|
nomeCliente: r.nome_cliente,
|
||||||
|
razaoCliente: r.razao_cliente,
|
||||||
|
codVendedor: r.cod_vendedor !== null ? Number(r.cod_vendedor) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
|
||||||
|
await this.assertClienteDaCarteira(idCorrent);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
const esc = (s: string) => s.replace(/'/g, "''");
|
||||||
|
const opt = (v: string | undefined) => (v ? `'${esc(v)}'` : 'NULL');
|
||||||
|
|
||||||
|
const insertRows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
INSERT INTO sar.contatos_novos (
|
||||||
|
id_empresa, cod_vendedor, id_corrent,
|
||||||
|
nome, empresa, cargo, departamento,
|
||||||
|
dt_aniversario, telefone, ramal, celular, whatsapp, email, anotacoes
|
||||||
|
) VALUES (
|
||||||
|
${idEmpresa}, ${codVendedor}, ${idCorrent},
|
||||||
|
'${esc(dto.nome)}',
|
||||||
|
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
|
||||||
|
${dto.dtAniversario ? `'${esc(dto.dtAniversario)}'` : 'NULL'},
|
||||||
|
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
|
||||||
|
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
|
||||||
|
)
|
||||||
|
RETURNING id::text
|
||||||
|
`);
|
||||||
|
|
||||||
|
const id = insertRows[0]?.id;
|
||||||
|
if (!id) throw new Error('Falha ao inserir contato');
|
||||||
|
|
||||||
|
interface ResultRow {
|
||||||
|
id: string;
|
||||||
|
id_contato_erp: number | null;
|
||||||
|
sincronizado: boolean;
|
||||||
|
erro_sync: string | null;
|
||||||
|
}
|
||||||
|
const rows = await prisma.$queryRawUnsafe<ResultRow[]>(`
|
||||||
|
SELECT id::text, id_contato_erp, sincronizado, erro_sync
|
||||||
|
FROM sar.contatos_novos WHERE id = '${id}'
|
||||||
|
`);
|
||||||
|
|
||||||
|
const r = rows[0];
|
||||||
|
if (!r) throw new Error('Falha ao recuperar contato inserido');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
idContatoErp: r.id_contato_erp !== null ? Number(r.id_contato_erp) : null,
|
||||||
|
sincronizado: r.sincronizado,
|
||||||
|
erroSync: r.erro_sync,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
num_ped: string;
|
||||||
|
situa: number;
|
||||||
|
dt_pedido: Date;
|
||||||
|
total: string;
|
||||||
|
fonte: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT id::text AS id,
|
||||||
|
num_ped_sar AS num_ped,
|
||||||
|
situa,
|
||||||
|
dt_pedido,
|
||||||
|
total::text,
|
||||||
|
'sar' AS fonte
|
||||||
|
FROM sar.pedidos
|
||||||
|
WHERE id_cliente = ${idCliente}
|
||||||
|
AND id_empresa = ${idEmpresa}
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT ('erp-' || id_pedido::text) AS id,
|
||||||
|
COALESCE(NULLIF(TRIM(num_ped_sar::text),''), numero::text) AS num_ped,
|
||||||
|
CASE WHEN situa = 5 THEN 3 ELSE situa END AS situa,
|
||||||
|
dt_pedido,
|
||||||
|
total::text,
|
||||||
|
'erp' AS fonte
|
||||||
|
FROM sar.vw_pedidos_erp
|
||||||
|
WHERE id_cliente = ${idCliente}
|
||||||
|
AND id_empresa = ${idEmpresa}
|
||||||
|
AND situa != 5
|
||||||
|
|
||||||
|
ORDER BY dt_pedido DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
numPedSar: r.num_ped ?? r.id,
|
||||||
|
idCliente,
|
||||||
|
nomeCliente: null,
|
||||||
|
razaoCliente: null,
|
||||||
|
codVendedor: 0,
|
||||||
|
nomeVendedor: null,
|
||||||
|
situa: Number(r.situa),
|
||||||
|
dtPedido: r.dt_pedido.toISOString(),
|
||||||
|
total: r.total ?? '0',
|
||||||
|
descontoPerc: '0',
|
||||||
|
obs: null,
|
||||||
|
createdAt: r.dt_pedido.toISOString(),
|
||||||
|
fonte: r.fonte as 'sar' | 'erp',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id_produto: number;
|
||||||
|
codigo: string;
|
||||||
|
descricao: string;
|
||||||
|
unidade: string | null;
|
||||||
|
qtd_total: string;
|
||||||
|
num_pedidos: string;
|
||||||
|
ultima_compra: Date;
|
||||||
|
ultimo_preco: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT
|
||||||
|
i.id_produto,
|
||||||
|
TRIM(p.codigo) AS codigo,
|
||||||
|
TRIM(p.descricao) AS descricao,
|
||||||
|
TRIM(p.unidade) AS unidade,
|
||||||
|
SUM(i.qtd)::numeric(15,3)::text AS qtd_total,
|
||||||
|
COUNT(DISTINCT ped.id_pedido)::text AS num_pedidos,
|
||||||
|
MAX(ped.dt_pedido) AS ultima_compra,
|
||||||
|
(
|
||||||
|
SELECT i2.preco_unitario::numeric(15,2)::text
|
||||||
|
FROM sar.vw_peditens_erp i2
|
||||||
|
JOIN sar.vw_pedidos_erp p2 ON p2.id_pedido = i2.id_pedido
|
||||||
|
WHERE i2.id_produto = i.id_produto
|
||||||
|
AND p2.id_cliente = ped.id_cliente
|
||||||
|
AND p2.id_empresa = ${idEmpresa}
|
||||||
|
AND p2.situa NOT IN (5)
|
||||||
|
ORDER BY p2.dt_pedido DESC, p2.id_pedido DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS ultimo_preco
|
||||||
|
FROM sar.vw_pedidos_erp ped
|
||||||
|
JOIN sar.vw_peditens_erp i ON i.id_pedido = ped.id_pedido
|
||||||
|
JOIN sar.vw_produtos p ON p.id_erp = i.id_produto
|
||||||
|
AND p.id_empresa = ${idEmpresaMatriz}
|
||||||
|
WHERE ped.id_cliente = ${idCliente}
|
||||||
|
AND ped.id_empresa = ${idEmpresa}
|
||||||
|
AND ped.situa NOT IN (5)
|
||||||
|
GROUP BY i.id_produto, p.codigo, p.descricao, p.unidade, ped.id_cliente
|
||||||
|
ORDER BY SUM(i.qtd) DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
idProduto: Number(r.id_produto),
|
||||||
|
codProduto: r.codigo ?? '',
|
||||||
|
descricao: r.descricao,
|
||||||
|
unidade: r.unidade,
|
||||||
|
qtdTotal: parseFloat(r.qtd_total ?? '0'),
|
||||||
|
numPedidos: parseInt(r.num_pedidos ?? '0', 10),
|
||||||
|
ultimaCompra: (r.ultima_compra as Date).toISOString().slice(0, 10),
|
||||||
|
ultimoPreco: parseFloat(r.ultimo_preco ?? '0'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
qtd_aberto: string;
|
||||||
|
total_aberto: string;
|
||||||
|
qtd_vencido: string;
|
||||||
|
total_vencido: string;
|
||||||
|
maior_atraso_dias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::text AS qtd_aberto,
|
||||||
|
COALESCE(SUM(saldo), 0)::numeric(15,2)::text AS total_aberto,
|
||||||
|
(COUNT(*) FILTER (WHERE dt_vencimento < CURRENT_DATE))::text AS qtd_vencido,
|
||||||
|
COALESCE(SUM(saldo) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::numeric(15,2)::text AS total_vencido,
|
||||||
|
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
|
||||||
|
FROM sar.vw_ctr
|
||||||
|
WHERE id_cliente = ${idCliente}
|
||||||
|
AND id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND situacao = 'A'
|
||||||
|
AND saldo > 0
|
||||||
|
`);
|
||||||
|
|
||||||
|
const r = rows[0];
|
||||||
|
return {
|
||||||
|
qtdAberto: parseInt(r?.qtd_aberto ?? '0', 10),
|
||||||
|
totalAberto: parseFloat(r?.total_aberto ?? '0'),
|
||||||
|
qtdVencido: parseInt(r?.qtd_vencido ?? '0', 10),
|
||||||
|
totalVencido: parseFloat(r?.total_vencido ?? '0'),
|
||||||
|
maiorAtrasoDias: parseInt(r?.maior_atraso_dias ?? '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(idCliente: number): Promise<ClientDetail> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<ClientRow[]>(`
|
||||||
|
SELECT
|
||||||
|
c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email,
|
||||||
|
c.telefone, c.cod_vendedor, ${NOME_VENDEDOR_SUBQ}, c.limite_credito::text,
|
||||||
|
c.ativo, c.pessoa, c.inscricao_estadual, c.endereco, c.num_endereco,
|
||||||
|
c.bairro, c.cep, c.ddd, c.obs, c.cod_pauta,
|
||||||
|
c.dt_cadastro::text, c.dt_atual::text,
|
||||||
|
GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra
|
||||||
|
FROM vw_clientes c
|
||||||
|
${pedidosJoins(idEmpresa)}
|
||||||
|
WHERE c.id_cliente = ${idCliente}
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const r = rows[0];
|
||||||
|
if (!r) throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
idCliente: Number(r.id_cliente),
|
||||||
|
|
||||||
|
idEmpresa: Number(r.id_empresa),
|
||||||
|
nome: r.nome,
|
||||||
|
razao: r.razao,
|
||||||
|
cgcpf: r.cgcpf,
|
||||||
|
email: r.email,
|
||||||
|
telefone: r.telefone,
|
||||||
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome_vendedor ?? null,
|
||||||
|
limiteCreditoStr: r.limite_credito,
|
||||||
|
activityStatus: activityStatus(r.dt_ultima_compra),
|
||||||
|
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
|
||||||
|
ativo: Number(r.ativo),
|
||||||
|
pessoa: r.pessoa !== null ? Number(r.pessoa) : null,
|
||||||
|
inscricaoEstadual: r.inscricao_estadual,
|
||||||
|
endereco: r.endereco,
|
||||||
|
numEndereco: r.num_endereco,
|
||||||
|
bairro: r.bairro,
|
||||||
|
cep: r.cep,
|
||||||
|
ddd: r.ddd,
|
||||||
|
obs: r.obs,
|
||||||
|
codPauta: r.cod_pauta !== null ? Number(r.cod_pauta) : null,
|
||||||
|
dtCadastro: r.dt_cadastro,
|
||||||
|
dtAtual: r.dt_atual,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
|
type CtrRow = {
|
||||||
|
id_ctr: number;
|
||||||
|
numero: string;
|
||||||
|
prefixo: string;
|
||||||
|
dt_emissao: Date;
|
||||||
|
dt_vencimento: Date;
|
||||||
|
saldo: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<CtrRow[]>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id_ctr,
|
||||||
|
TRIM(numero) AS numero,
|
||||||
|
TRIM(prefixo) AS prefixo,
|
||||||
|
dt_emissao,
|
||||||
|
dt_vencimento,
|
||||||
|
saldo::text AS saldo
|
||||||
|
FROM sar.vw_ctr
|
||||||
|
WHERE id_cliente = $1
|
||||||
|
AND id_empresa = $3
|
||||||
|
AND situacao = 'A'
|
||||||
|
AND saldo > 0
|
||||||
|
ORDER BY dt_vencimento ASC
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
idCliente,
|
||||||
|
limit,
|
||||||
|
idEmpresaMatriz,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toDate = (d: Date | string): string =>
|
||||||
|
d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10);
|
||||||
|
|
||||||
|
return rows.map((r: CtrRow) => ({
|
||||||
|
idCtr: Number(r.id_ctr),
|
||||||
|
numero: r.numero,
|
||||||
|
prefixo: r.prefixo,
|
||||||
|
dtEmissao: toDate(r.dt_emissao),
|
||||||
|
dtVencimento: toDate(r.dt_vencimento),
|
||||||
|
saldo: Number(r.saldo),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
// NF fica na matriz; pedidos podem ter origem na matriz ou na empresa fiscal (matriz+9000)
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
const idEmpresaFiscal = idEmpresaMatriz + 9000;
|
||||||
|
|
||||||
|
type NfRow = {
|
||||||
|
id_nf: number;
|
||||||
|
numero: number;
|
||||||
|
serie: string;
|
||||||
|
dt_emissao: Date;
|
||||||
|
vl_nf: string;
|
||||||
|
chave_acesso_nfe: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<NfRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id_nf, numero, serie, dt_emissao, vl_nf, chave_acesso_nfe
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (nf.id_nf)
|
||||||
|
nf.id_nf,
|
||||||
|
nf.numero,
|
||||||
|
TRIM(nf.serie) AS serie,
|
||||||
|
nf.dt_emissao,
|
||||||
|
nf.vl_nf::text AS vl_nf,
|
||||||
|
TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe
|
||||||
|
FROM sar.vw_nf nf
|
||||||
|
JOIN sar.vw_pedidos_erp p
|
||||||
|
ON p.numero = nf.num_entrega
|
||||||
|
AND p.tipo = 'E'
|
||||||
|
AND p.id_empresa IN ($3, $4)
|
||||||
|
WHERE p.id_cliente = $1
|
||||||
|
AND nf.id_empresa = $3
|
||||||
|
AND nf.status = 'E'
|
||||||
|
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
|
||||||
|
ORDER BY nf.id_nf
|
||||||
|
) sub
|
||||||
|
ORDER BY dt_emissao DESC
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
idCliente,
|
||||||
|
limit,
|
||||||
|
idEmpresaMatriz,
|
||||||
|
idEmpresaFiscal,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r: NfRow) => ({
|
||||||
|
idNf: Number(r.id_nf),
|
||||||
|
numero: Number(r.numero),
|
||||||
|
serie: r.serie?.trim() ?? '',
|
||||||
|
dtEmissao:
|
||||||
|
(r.dt_emissao instanceof Date
|
||||||
|
? r.dt_emissao.toISOString().split('T')[0]
|
||||||
|
: String(r.dt_emissao)) ?? '',
|
||||||
|
vlNf: Number(r.vl_nf),
|
||||||
|
chaveAcessoNfe: r.chave_acesso_nfe?.trim() || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,10 @@ export const EnvSchema = z
|
|||||||
API_PORT: z.coerce.number().int().positive().default(3000),
|
API_PORT: z.coerce.number().int().positive().default(3000),
|
||||||
API_HOST: z.string().min(1).default('0.0.0.0'),
|
API_HOST: z.string().min(1).default('0.0.0.0'),
|
||||||
API_GLOBAL_PREFIX: z.string().min(1).default('api'),
|
API_GLOBAL_PREFIX: z.string().min(1).default('api'),
|
||||||
API_VERSION: z.string().regex(/^v\d+$/).default('v1'),
|
API_VERSION: z
|
||||||
|
.string()
|
||||||
|
.regex(/^v\d+$/)
|
||||||
|
.default('v1'),
|
||||||
|
|
||||||
// CORS — origens permitidas (Web em dev: http://localhost:4200)
|
// CORS — origens permitidas (Web em dev: http://localhost:4200)
|
||||||
CORS_ORIGINS: z
|
CORS_ORIGINS: z
|
||||||
@@ -28,13 +31,20 @@ export const EnvSchema = z
|
|||||||
|
|
||||||
// Master-login (DEV stub — IdP real virá na próxima sessão)
|
// Master-login (DEV stub — IdP real virá na próxima sessão)
|
||||||
MASTER_LOGIN_URL: z.url().default('http://localhost:3000/auth/dev'),
|
MASTER_LOGIN_URL: z.url().default('http://localhost:3000/auth/dev'),
|
||||||
MASTER_LOGIN_JWT_SECRET: z.string().min(32).default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'),
|
MASTER_LOGIN_JWT_SECRET: z
|
||||||
|
.string()
|
||||||
|
.min(32)
|
||||||
|
.default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'),
|
||||||
JWT_ACCESS_EXPIRATION: z.coerce.number().int().positive().default(900),
|
JWT_ACCESS_EXPIRATION: z.coerce.number().int().positive().default(900),
|
||||||
JWT_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000),
|
JWT_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000),
|
||||||
|
|
||||||
// Multi-tenancy — workspace de dev (até master-login real entrar)
|
// Multi-tenancy — workspace de dev (até master-login real entrar)
|
||||||
DEFAULT_WORKSPACE_ID: z.string().min(1).default('dev-workspace'),
|
DEFAULT_WORKSPACE_ID: z.string().min(1).default('dev-workspace'),
|
||||||
|
|
||||||
|
// Representante fixo de dev — forçado no guard enquanto não há login real
|
||||||
|
DEV_REP_CODE: z.coerce.number().int().positive().default(29),
|
||||||
|
DEV_EMPRESA_ID: z.coerce.number().int().positive().default(1),
|
||||||
|
|
||||||
// Postgres (Prisma virá depois)
|
// Postgres (Prisma virá depois)
|
||||||
DATABASE_URL: z.string().optional(),
|
DATABASE_URL: z.string().optional(),
|
||||||
MIGRATION_DATABASE_URL: z.string().optional(),
|
MIGRATION_DATABASE_URL: z.string().optional(),
|
||||||
@@ -61,6 +71,11 @@ export const EnvSchema = z
|
|||||||
OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0),
|
OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0),
|
||||||
SENTRY_DSN: z.string().optional(),
|
SENTRY_DSN: z.string().optional(),
|
||||||
|
|
||||||
|
// Web Push VAPID (C6) — gerado via web-push generateVAPIDKeys()
|
||||||
|
VAPID_PUBLIC_KEY: z.string().optional(),
|
||||||
|
VAPID_PRIVATE_KEY: z.string().optional(),
|
||||||
|
VAPID_CONTACT: z.string().default('mailto:noreply@sar.dev'),
|
||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
GROWTHBOOK_API_HOST: z.string().optional(),
|
GROWTHBOOK_API_HOST: z.string().optional(),
|
||||||
GROWTHBOOK_CLIENT_KEY: z.string().optional(),
|
GROWTHBOOK_CLIENT_KEY: z.string().optional(),
|
||||||
@@ -74,11 +89,16 @@ export const EnvSchema = z
|
|||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
path: ['MASTER_LOGIN_JWT_SECRET'],
|
path: ['MASTER_LOGIN_JWT_SECRET'],
|
||||||
message: 'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).',
|
message:
|
||||||
|
'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!env.DATABASE_URL) {
|
if (!env.DATABASE_URL) {
|
||||||
ctx.addIssue({ code: 'custom', path: ['DATABASE_URL'], message: 'obrigatório em produção' });
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['DATABASE_URL'],
|
||||||
|
message: 'obrigatório em produção',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
45
apps/api/src/app/dashboard/dashboard.controller.ts
Normal file
45
apps/api/src/app/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
|
@Controller({ path: 'dashboard' })
|
||||||
|
export class DashboardController {
|
||||||
|
constructor(
|
||||||
|
private readonly dashboard: DashboardService,
|
||||||
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: dashboards gerenciais agregam dados da empresa toda (faturamento,
|
||||||
|
// ranking de todos os reps) — rep não pode acessá-los.
|
||||||
|
private assertGestor(): void {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role === 'rep') {
|
||||||
|
throw new ForbiddenException('Apenas supervisores e gerentes podem acessar este painel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('rep')
|
||||||
|
repDashboard(): Promise<RepDashboard> {
|
||||||
|
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('supervisor')
|
||||||
|
supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.dashboard.supervisorDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('manager')
|
||||||
|
managerDashboard(
|
||||||
|
@Query('mes') mes?: string,
|
||||||
|
@Query('ano') ano?: string,
|
||||||
|
): Promise<ManagerDashboard> {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.dashboard.managerDashboard(
|
||||||
|
mes ? parseInt(mes, 10) : undefined,
|
||||||
|
ano ? parseInt(ano, 10) : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {}
|
||||||
754
apps/api/src/app/dashboard/dashboard.service.ts
Normal file
754
apps/api/src/app/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type {
|
||||||
|
RepDashboard,
|
||||||
|
SupervisorDashboard,
|
||||||
|
ManagerDashboard,
|
||||||
|
RankingRep,
|
||||||
|
PositivacaoRep,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||||
|
|
||||||
|
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
|
||||||
|
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
|
||||||
|
const SITUA_PENDENTE = 1;
|
||||||
|
|
||||||
|
// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
|
||||||
|
const TIPO_META_GLOBAL = 'GL';
|
||||||
|
const TIPO_META_GRUPO = 'GR';
|
||||||
|
|
||||||
|
// Metas/produtos vivem na MATRIZ; pedidos usam idEmpresa cru (ex.: 9001 → matriz 1).
|
||||||
|
function matrizEmpresa(idEmpresa: number): number {
|
||||||
|
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetaErpRow {
|
||||||
|
tipo: string;
|
||||||
|
cod_grupo: number | null;
|
||||||
|
desc_grupo: string | null;
|
||||||
|
valor: string;
|
||||||
|
qtdade: string;
|
||||||
|
peso: string;
|
||||||
|
vl_fator: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RealizadoGrupoRow {
|
||||||
|
cod_grupo: number | null;
|
||||||
|
grupo: string | null;
|
||||||
|
pedidos: string;
|
||||||
|
valor: string;
|
||||||
|
qtd: string;
|
||||||
|
peso: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NaoPositivadoRow {
|
||||||
|
id_cliente: number;
|
||||||
|
nome: string;
|
||||||
|
razao: string | null;
|
||||||
|
dt_ultimo_pedido: string | null; // YYYY-MM-DD ou null
|
||||||
|
dias_sem_pedido: number | null;
|
||||||
|
comprou_antes: boolean;
|
||||||
|
whatsapp: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InativosPorRepRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome_vendedor: string | null;
|
||||||
|
inativos_count: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardService {
|
||||||
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
async repDashboard(userId: string): Promise<RepDashboard> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth() + 1;
|
||||||
|
|
||||||
|
const monthStart = new Date(year, month - 1, 1);
|
||||||
|
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
|
||||||
|
|
||||||
|
// 1. Metas do mês — vw_metas vive na matriz (normaliza 9001→1).
|
||||||
|
// GL = meta global; GR = meta por grupo. A dimensão segue o que o ERP tiver.
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||||
|
const metaErpRows = await prisma.$queryRawUnsafe<MetaErpRow[]>(`
|
||||||
|
SELECT TRIM(tipo) AS tipo, cod_grupo, TRIM(desc_grupo) AS desc_grupo,
|
||||||
|
valor::text AS valor, qtdade::text AS qtdade,
|
||||||
|
peso::text AS peso, vl_fator::text AS vl_fator
|
||||||
|
FROM vw_metas
|
||||||
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND cod_vendedor = ${codVendedor}
|
||||||
|
AND ano = ${year}
|
||||||
|
AND mes = ${month}
|
||||||
|
AND TRIM(tipo) IN ('${TIPO_META_GLOBAL}', '${TIPO_META_GRUPO}')
|
||||||
|
`);
|
||||||
|
const glRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GLOBAL);
|
||||||
|
const grRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GRUPO);
|
||||||
|
// Total global: usa GL se houver; senão soma as metas por grupo (GR).
|
||||||
|
const targetAmount = glRows.length
|
||||||
|
? glRows.reduce((a, m) => a + Number(m.valor), 0)
|
||||||
|
: grRows.reduce((a, m) => a + Number(m.valor), 0);
|
||||||
|
const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const);
|
||||||
|
|
||||||
|
// 2. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
|
||||||
|
// Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido.
|
||||||
|
const monthStartStr = monthStart.toISOString().slice(0, 10);
|
||||||
|
const monthEndStr = monthEnd.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
interface TotalRow {
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
interface CountRow {
|
||||||
|
count: string;
|
||||||
|
}
|
||||||
|
interface RecentRow {
|
||||||
|
id_pedido: number;
|
||||||
|
num_ped_sar: string;
|
||||||
|
numero: number;
|
||||||
|
id_cliente: number;
|
||||||
|
nome_cliente: string | null;
|
||||||
|
razao_cliente: string | null;
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome_vendedor: string | null;
|
||||||
|
situa: number;
|
||||||
|
status_descr: string;
|
||||||
|
dt_pedido: Date;
|
||||||
|
total: string;
|
||||||
|
desconto_perc: string;
|
||||||
|
obs: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RealizadoMesRow {
|
||||||
|
mes: string;
|
||||||
|
valor: string;
|
||||||
|
}
|
||||||
|
interface MetaMesRow {
|
||||||
|
mes: string;
|
||||||
|
tipo: string;
|
||||||
|
valor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
atingidoRows,
|
||||||
|
pedidosMesRows,
|
||||||
|
recentRows,
|
||||||
|
realizadoGrupoRows,
|
||||||
|
realizadoMesRows,
|
||||||
|
metaMesRows,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||||
|
SELECT COALESCE(SUM(total), 0)::text AS total
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND cod_vendedor = ${codVendedor}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<CountRow[]>(`
|
||||||
|
SELECT COUNT(*)::text AS count
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND cod_vendedor = ${codVendedor}
|
||||||
|
AND situa != 5
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<RecentRow[]>(`
|
||||||
|
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
|
||||||
|
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
|
||||||
|
c.nome AS nome_cliente, c.razao AS razao_cliente,
|
||||||
|
(SELECT r.nome FROM vw_representantes r
|
||||||
|
WHERE r.codigo = e.cod_vendedor
|
||||||
|
LIMIT 1) AS nome_vendedor
|
||||||
|
FROM vw_pedidos_erp e
|
||||||
|
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
|
||||||
|
WHERE e.id_empresa = ${idEmpresa}
|
||||||
|
AND e.cod_vendedor = ${codVendedor}
|
||||||
|
AND e.situa != 5
|
||||||
|
AND e.dt_pedido >= '${new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}'
|
||||||
|
ORDER BY e.dt_pedido DESC
|
||||||
|
LIMIT 10
|
||||||
|
`),
|
||||||
|
// Realizado por grupo — itens faturados (situa 2/4) → produto (matriz) → cod_grupo.
|
||||||
|
// peso = qtd × peso_líquido do produto; pedidos = nº de pedidos distintos.
|
||||||
|
prisma.$queryRawUnsafe<RealizadoGrupoRow[]>(`
|
||||||
|
SELECT p.cod_grupo,
|
||||||
|
COALESCE(NULLIF(TRIM(p.grupo), ''), p.cod_grupo::text) AS grupo,
|
||||||
|
COUNT(DISTINCT pi.id_pedido)::text AS pedidos,
|
||||||
|
COALESCE(SUM(pi.total), 0)::text AS valor,
|
||||||
|
COALESCE(SUM(pi.qtd), 0)::text AS qtd,
|
||||||
|
COALESCE(SUM(pi.qtd * COALESCE(p.peso_liquido, 0)), 0)::text AS peso
|
||||||
|
FROM vw_peditens_erp pi
|
||||||
|
JOIN vw_pedidos_erp e ON e.id_pedido = pi.id_pedido
|
||||||
|
JOIN vw_produtos p ON p.id_erp = pi.id_produto AND p.id_empresa = ${idEmpresaMatriz}
|
||||||
|
WHERE e.id_empresa = ${idEmpresa}
|
||||||
|
AND e.cod_vendedor = ${codVendedor}
|
||||||
|
AND e.situa NOT IN (1, 5)
|
||||||
|
AND e.dt_pedido >= '${monthStartStr}'
|
||||||
|
AND e.dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY p.cod_grupo, grupo
|
||||||
|
`),
|
||||||
|
// Realizado por mês do ano corrente.
|
||||||
|
prisma.$queryRawUnsafe<RealizadoMesRow[]>(`
|
||||||
|
SELECT EXTRACT(MONTH FROM dt_pedido)::int::text AS mes,
|
||||||
|
COALESCE(SUM(total), 0)::text AS valor
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND cod_vendedor = ${codVendedor}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND EXTRACT(YEAR FROM dt_pedido) = ${year}
|
||||||
|
GROUP BY mes
|
||||||
|
ORDER BY mes
|
||||||
|
`),
|
||||||
|
// Meta (GL + GR) por mês do ano corrente.
|
||||||
|
prisma.$queryRawUnsafe<MetaMesRow[]>(`
|
||||||
|
SELECT mes::text, TRIM(tipo) AS tipo,
|
||||||
|
COALESCE(SUM(valor), 0)::text AS valor
|
||||||
|
FROM vw_metas
|
||||||
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND cod_vendedor = ${codVendedor}
|
||||||
|
AND ano = ${year}
|
||||||
|
AND TRIM(tipo) IN ('GL', 'GR')
|
||||||
|
GROUP BY mes, tipo
|
||||||
|
ORDER BY mes
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const atingido = Number(atingidoRows[0]?.total ?? 0);
|
||||||
|
const pedidosMes = Number(pedidosMesRows[0]?.count ?? 0);
|
||||||
|
const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0;
|
||||||
|
const falta = Math.max(0, targetAmount - atingido);
|
||||||
|
|
||||||
|
// Clientes não positivados no mês: ativos do representante sem pedido desde monthStart.
|
||||||
|
// Junta o último pedido histórico (ERP+SAR) e o primeiro whatsapp ativo dos contatos.
|
||||||
|
const naoPositivadosRows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
|
||||||
|
WITH ultimo_pedido AS (
|
||||||
|
SELECT id_cliente,
|
||||||
|
MAX(dt_pedido) AS dt_max
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE situa NOT IN (5)
|
||||||
|
AND id_empresa = ${idEmpresa}
|
||||||
|
GROUP BY id_cliente
|
||||||
|
),
|
||||||
|
pedido_mes AS (
|
||||||
|
SELECT DISTINCT id_cliente
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE situa NOT IN (5)
|
||||||
|
AND id_empresa = ${idEmpresa}
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.id_cliente,
|
||||||
|
TRIM(c.nome) AS nome,
|
||||||
|
TRIM(c.razao) AS razao,
|
||||||
|
TO_CHAR(up.dt_max, 'YYYY-MM-DD') AS dt_ultimo_pedido,
|
||||||
|
(CURRENT_DATE - up.dt_max::date) AS dias_sem_pedido,
|
||||||
|
(up.dt_max IS NOT NULL) AS comprou_antes,
|
||||||
|
ct.whatsapp
|
||||||
|
FROM vw_clientes c
|
||||||
|
LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
|
||||||
|
LEFT JOIN pedido_mes pm ON pm.id_cliente = c.id_cliente
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT TRIM(whatsapp) AS whatsapp
|
||||||
|
FROM sar.vw_contatos
|
||||||
|
WHERE id_corrent = c.id_cliente
|
||||||
|
AND whatsapp IS NOT NULL AND TRIM(whatsapp) != ''
|
||||||
|
AND ativo = 1
|
||||||
|
LIMIT 1
|
||||||
|
) ct ON true
|
||||||
|
WHERE c.cod_vendedor = ${codVendedor}
|
||||||
|
AND c.ativo = 1
|
||||||
|
AND pm.id_cliente IS NULL
|
||||||
|
ORDER BY up.dt_max ASC NULLS FIRST
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo.
|
||||||
|
const realPorGrupo = new Map<number, RealizadoGrupoRow>();
|
||||||
|
for (const r of realizadoGrupoRows) {
|
||||||
|
if (r.cod_grupo != null) realPorGrupo.set(Number(r.cod_grupo), r);
|
||||||
|
}
|
||||||
|
const metasPorGrupo = grRows
|
||||||
|
.map((m) => {
|
||||||
|
const cod = m.cod_grupo != null ? Number(m.cod_grupo) : null;
|
||||||
|
const real = cod != null ? realPorGrupo.get(cod) : undefined;
|
||||||
|
const valorMeta = Number(m.valor);
|
||||||
|
const valorReal = real ? Number(real.valor) : 0;
|
||||||
|
const pesoMeta = Number(m.peso);
|
||||||
|
const pesoReal = real ? Number(real.peso) : 0;
|
||||||
|
return {
|
||||||
|
codigo: cod,
|
||||||
|
rotulo: m.desc_grupo || real?.grupo || `Grupo ${cod ?? '?'}`,
|
||||||
|
pedidos: real ? Number(real.pedidos) : 0,
|
||||||
|
valorMeta,
|
||||||
|
valorReal,
|
||||||
|
qtdMeta: Number(m.qtdade),
|
||||||
|
qtdReal: real ? Number(real.qtd) : 0,
|
||||||
|
pesoMeta,
|
||||||
|
pesoReal,
|
||||||
|
fatorMeta: Number(m.vl_fator),
|
||||||
|
fatorReal: pesoReal > 0 ? valorReal / pesoReal : 0,
|
||||||
|
pct: valorMeta > 0 ? Math.round((valorReal / valorMeta) * 100) : 0,
|
||||||
|
falta: Math.max(0, valorMeta - valorReal),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.valorMeta - a.valorMeta);
|
||||||
|
|
||||||
|
// Deduplica por idCliente — vw_clientes pode retornar o mesmo cliente em
|
||||||
|
// mais de uma empresa; mantemos a primeira ocorrência (ORDER BY dt_max ASC NULLS FIRST).
|
||||||
|
const seenNP = new Set<number>();
|
||||||
|
const naoPositivados = naoPositivadosRows
|
||||||
|
.filter((c) => {
|
||||||
|
const id = Number(c.id_cliente);
|
||||||
|
if (seenNP.has(id)) return false;
|
||||||
|
seenNP.add(id);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((c) => ({
|
||||||
|
idCliente: Number(c.id_cliente),
|
||||||
|
nome: c.nome,
|
||||||
|
razao: c.razao ?? null,
|
||||||
|
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
|
||||||
|
ultimoPedido: c.dt_ultimo_pedido ?? null,
|
||||||
|
comprouAntes: Boolean(c.comprou_antes),
|
||||||
|
whatsapp: c.whatsapp ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Monta histório mensal: 12 meses do ano, cruzando realizado e meta.
|
||||||
|
const realizadoPorMes = new Map(realizadoMesRows.map((r) => [Number(r.mes), Number(r.valor)]));
|
||||||
|
const metaGlPorMes = new Map<number, number>();
|
||||||
|
const metaGrPorMes = new Map<number, number>();
|
||||||
|
for (const r of metaMesRows) {
|
||||||
|
const m = Number(r.mes);
|
||||||
|
if (r.tipo === 'GL') metaGlPorMes.set(m, (metaGlPorMes.get(m) ?? 0) + Number(r.valor));
|
||||||
|
else metaGrPorMes.set(m, (metaGrPorMes.get(m) ?? 0) + Number(r.valor));
|
||||||
|
}
|
||||||
|
const historicoMensal = Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const m = i + 1;
|
||||||
|
const valor = realizadoPorMes.get(m) ?? 0;
|
||||||
|
const meta = metaGlPorMes.has(m) ? (metaGlPorMes.get(m) ?? 0) : (metaGrPorMes.get(m) ?? 0);
|
||||||
|
return { mes: m, valor, meta, atingiu: meta > 0 && valor >= meta };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: { atingido, total: targetAmount, pct, falta },
|
||||||
|
metaDimensao,
|
||||||
|
metasPorGrupo,
|
||||||
|
pedidosMes,
|
||||||
|
historicoMensal,
|
||||||
|
pedidosRecentes: recentRows.map((o) => ({
|
||||||
|
id: `erp-${o.id_pedido}`,
|
||||||
|
numPedSar: (o.num_ped_sar ?? '').trim(),
|
||||||
|
numero: Number(o.numero),
|
||||||
|
idCliente: Number(o.id_cliente),
|
||||||
|
nomeCliente: o.nome_cliente ?? null,
|
||||||
|
razaoCliente: o.razao_cliente ?? null,
|
||||||
|
codVendedor: Number(o.cod_vendedor),
|
||||||
|
nomeVendedor: o.nome_vendedor ?? null,
|
||||||
|
situa: Number(o.situa),
|
||||||
|
statusDescr: o.status_descr,
|
||||||
|
dtPedido: new Date(o.dt_pedido).toISOString(),
|
||||||
|
total: o.total ?? '0',
|
||||||
|
descontoPerc: o.desconto_perc ?? '0',
|
||||||
|
obs: o.obs ?? null,
|
||||||
|
createdAt: new Date(o.dt_pedido).toISOString(),
|
||||||
|
fonte: 'erp' as const,
|
||||||
|
})),
|
||||||
|
naoPositivados,
|
||||||
|
totalNaoPositivados: naoPositivados.length,
|
||||||
|
syncedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async managerDashboard(mes?: number, ano?: number): Promise<ManagerDashboard> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
function matrizEmpresa(id: number): number {
|
||||||
|
return id > 9000 ? id - 9000 : id;
|
||||||
|
}
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||||
|
const year = ano ?? now.getFullYear();
|
||||||
|
const month = mes ?? now.getMonth() + 1;
|
||||||
|
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
|
||||||
|
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
interface TotalRow {
|
||||||
|
count: string;
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
interface RankingRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome: string | null;
|
||||||
|
pedidos: string;
|
||||||
|
clientes_atendidos: string;
|
||||||
|
faturamento: string;
|
||||||
|
meta_valor: string | null;
|
||||||
|
}
|
||||||
|
interface PromoRow {
|
||||||
|
count: string;
|
||||||
|
}
|
||||||
|
interface MetaTotalRow {
|
||||||
|
meta_total: string;
|
||||||
|
}
|
||||||
|
interface MetaGrupoRow {
|
||||||
|
cod_grupo: number | null;
|
||||||
|
desc_grupo: string | null;
|
||||||
|
valor: string;
|
||||||
|
qtdade: string;
|
||||||
|
peso: string;
|
||||||
|
vl_fator: string;
|
||||||
|
}
|
||||||
|
interface PositivacaoRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome_vendedor: string | null;
|
||||||
|
total_clientes: string;
|
||||||
|
clientes_positivados: string;
|
||||||
|
}
|
||||||
|
interface PositivacaoDiaRow {
|
||||||
|
dia: string;
|
||||||
|
clientes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
statsRows,
|
||||||
|
rankingRows,
|
||||||
|
promoRows,
|
||||||
|
metaTotalRows,
|
||||||
|
positivacaoRows,
|
||||||
|
metaGrupoRows,
|
||||||
|
realizadoGrupoRows,
|
||||||
|
positivacaoDiariaRows,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||||
|
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<RankingRow[]>(`
|
||||||
|
SELECT p.cod_vendedor,
|
||||||
|
(SELECT r.nome FROM vw_representantes r
|
||||||
|
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome,
|
||||||
|
COUNT(*)::text AS pedidos,
|
||||||
|
COUNT(DISTINCT p.id_cliente)::text AS clientes_atendidos,
|
||||||
|
COALESCE(SUM(p.total), 0)::text AS faturamento,
|
||||||
|
(SELECT SUM(m.valor)::text FROM sar.vw_metas m
|
||||||
|
WHERE m.id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND m.cod_vendedor = p.cod_vendedor
|
||||||
|
AND m.ano = ${year}
|
||||||
|
AND m.mes = ${month}
|
||||||
|
AND TRIM(m.tipo) = 'GR') AS meta_valor
|
||||||
|
FROM vw_pedidos_erp p
|
||||||
|
WHERE p.id_empresa = ${idEmpresa}
|
||||||
|
AND p.situa NOT IN (1, 5)
|
||||||
|
AND p.dt_pedido >= '${monthStartStr}'
|
||||||
|
AND p.dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY p.cod_vendedor
|
||||||
|
ORDER BY SUM(p.total) DESC
|
||||||
|
LIMIT 10
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<PromoRow[]>(`
|
||||||
|
SELECT COUNT(*)::text AS count
|
||||||
|
FROM sar.promocoes
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND ativa = true
|
||||||
|
AND data_fim >= CURRENT_DATE
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<MetaTotalRow[]>(`
|
||||||
|
SELECT COALESCE(SUM(valor), 0)::text AS meta_total
|
||||||
|
FROM sar.vw_metas
|
||||||
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND ano = ${year}
|
||||||
|
AND mes = ${month}
|
||||||
|
AND TRIM(tipo) = 'GR'
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<PositivacaoRow[]>(`
|
||||||
|
SELECT
|
||||||
|
c.cod_vendedor,
|
||||||
|
(SELECT r.nome FROM vw_representantes r WHERE r.codigo = c.cod_vendedor LIMIT 1) AS nome_vendedor,
|
||||||
|
COUNT(DISTINCT c.id_cliente)::text AS total_clientes,
|
||||||
|
COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS clientes_positivados
|
||||||
|
FROM vw_clientes c
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DISTINCT id_cliente, cod_vendedor
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor
|
||||||
|
WHERE c.cod_vendedor > 0
|
||||||
|
GROUP BY c.cod_vendedor
|
||||||
|
HAVING COUNT(DISTINCT c.id_cliente) > 0
|
||||||
|
ORDER BY COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END) DESC
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<MetaGrupoRow[]>(`
|
||||||
|
SELECT cod_grupo, TRIM(desc_grupo) AS desc_grupo,
|
||||||
|
SUM(valor)::text AS valor,
|
||||||
|
SUM(qtdade)::text AS qtdade,
|
||||||
|
SUM(peso)::text AS peso,
|
||||||
|
AVG(vl_fator)::text AS vl_fator
|
||||||
|
FROM sar.vw_metas
|
||||||
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND ano = ${year}
|
||||||
|
AND mes = ${month}
|
||||||
|
AND TRIM(tipo) = 'GR'
|
||||||
|
GROUP BY cod_grupo, desc_grupo
|
||||||
|
ORDER BY SUM(valor) DESC
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<RealizadoGrupoRow[]>(`
|
||||||
|
SELECT p.cod_grupo,
|
||||||
|
COALESCE(NULLIF(TRIM(p.grupo), ''), p.cod_grupo::text) AS grupo,
|
||||||
|
COUNT(DISTINCT pi.id_pedido)::text AS pedidos,
|
||||||
|
COALESCE(SUM(pi.total), 0)::text AS valor,
|
||||||
|
COALESCE(SUM(pi.qtd), 0)::text AS qtd,
|
||||||
|
COALESCE(SUM(pi.qtd * COALESCE(p.peso_liquido, 0)), 0)::text AS peso
|
||||||
|
FROM vw_peditens_erp pi
|
||||||
|
JOIN vw_pedidos_erp e ON e.id_pedido = pi.id_pedido
|
||||||
|
JOIN vw_produtos p ON p.id_erp = pi.id_produto AND p.id_empresa = ${idEmpresaMatriz}
|
||||||
|
WHERE e.id_empresa = ${idEmpresa}
|
||||||
|
AND e.situa NOT IN (1, 5)
|
||||||
|
AND e.dt_pedido >= '${monthStartStr}'
|
||||||
|
AND e.dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY p.cod_grupo, grupo
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<PositivacaoDiaRow[]>(`
|
||||||
|
SELECT dt_pedido::date::text AS dia,
|
||||||
|
COUNT(DISTINCT id_cliente)::text AS clientes
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY dt_pedido::date
|
||||||
|
ORDER BY dia
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pedidosMes = Number(statsRows[0]?.count ?? 0);
|
||||||
|
const faturamentoMes = Number(statsRows[0]?.total ?? 0);
|
||||||
|
const ticketMedio = pedidosMes > 0 ? faturamentoMes / pedidosMes : 0;
|
||||||
|
|
||||||
|
const realGrupoPorCod = new Map<number, RealizadoGrupoRow>();
|
||||||
|
for (const r of realizadoGrupoRows) {
|
||||||
|
if (r.cod_grupo != null) realGrupoPorCod.set(Number(r.cod_grupo), r);
|
||||||
|
}
|
||||||
|
const metasPorGrupo = metaGrupoRows.map((m) => {
|
||||||
|
const cod = m.cod_grupo != null ? Number(m.cod_grupo) : null;
|
||||||
|
const real = cod != null ? realGrupoPorCod.get(cod) : undefined;
|
||||||
|
const valorMeta = Number(m.valor);
|
||||||
|
const valorReal = real ? Number(real.valor) : 0;
|
||||||
|
const pesoMeta = Number(m.peso);
|
||||||
|
const pesoReal = real ? Number(real.peso) : 0;
|
||||||
|
return {
|
||||||
|
codigo: cod,
|
||||||
|
rotulo: m.desc_grupo || real?.grupo || `Grupo ${cod ?? '?'}`,
|
||||||
|
pedidos: real ? Number(real.pedidos) : 0,
|
||||||
|
valorMeta,
|
||||||
|
valorReal,
|
||||||
|
qtdMeta: Number(m.qtdade),
|
||||||
|
qtdReal: real ? Number(real.qtd) : 0,
|
||||||
|
pesoMeta,
|
||||||
|
pesoReal,
|
||||||
|
fatorMeta: Number(m.vl_fator),
|
||||||
|
fatorReal: pesoReal > 0 ? valorReal / pesoReal : 0,
|
||||||
|
pct: valorMeta > 0 ? Math.round((valorReal / valorMeta) * 100) : 0,
|
||||||
|
falta: Math.max(0, valorMeta - valorReal),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankingReps: RankingRep[] = rankingRows.map((r) => {
|
||||||
|
const fat = Number(r.faturamento);
|
||||||
|
const meta = r.meta_valor ? Number(r.meta_valor) : 0;
|
||||||
|
return {
|
||||||
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome ?? null,
|
||||||
|
pedidos: Number(r.pedidos),
|
||||||
|
clientesAtendidos: Number(r.clientes_atendidos),
|
||||||
|
faturamento: fat,
|
||||||
|
pctMeta: meta > 0 ? Math.round((fat / meta) * 100) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
|
||||||
|
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
|
||||||
|
|
||||||
|
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
|
||||||
|
const total = Number(r.total_clientes);
|
||||||
|
const positivados = Number(r.clientes_positivados);
|
||||||
|
return {
|
||||||
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome_vendedor ?? null,
|
||||||
|
totalClientes: total,
|
||||||
|
clientesPositivados: positivados,
|
||||||
|
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
faturamentoMes,
|
||||||
|
pedidosMes,
|
||||||
|
ticketMedio,
|
||||||
|
totalReps,
|
||||||
|
promocoesAtivas: Number(promoRows[0]?.count ?? 0),
|
||||||
|
metaTotal,
|
||||||
|
metasPorGrupo,
|
||||||
|
rankingReps,
|
||||||
|
positivacaoReps,
|
||||||
|
positivacaoDiaria: positivacaoDiariaRows.map((r) => ({
|
||||||
|
dia: r.dia,
|
||||||
|
clientes: Number(r.clientes),
|
||||||
|
})),
|
||||||
|
syncedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes);
|
||||||
|
// gerente/admin acessando este painel veem a empresa toda.
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const team =
|
||||||
|
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null;
|
||||||
|
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
|
||||||
|
|
||||||
|
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
|
||||||
|
const approvalQueue = await prisma.pedido.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa,
|
||||||
|
situa: SITUA_PENDENTE,
|
||||||
|
...(team ? { codVendedor: { in: team } } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { dtPedido: 'asc' },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pedidos do dia — lê do ERP (situa != 5=Cancelado)
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const todayStr = todayStart.toISOString().slice(0, 10);
|
||||||
|
const lastWeekStr = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
const lastWeekEndStr = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
interface DayRow {
|
||||||
|
count: string;
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
const [todayRows, lastWeekRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<DayRow[]>(`
|
||||||
|
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}'
|
||||||
|
${teamSqlFilter('cod_vendedor')}
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<DayRow[]>(`
|
||||||
|
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa} AND situa != 5
|
||||||
|
AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}'
|
||||||
|
${teamSqlFilter('cod_vendedor')}
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Top 3 reps com mais clientes inativos (>30 dias sem compra no ERP)
|
||||||
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
|
||||||
|
SELECT inativos.cod_vendedor,
|
||||||
|
(SELECT r.nome FROM vw_representantes r
|
||||||
|
WHERE r.codigo = inativos.cod_vendedor
|
||||||
|
LIMIT 1) AS nome_vendedor,
|
||||||
|
COUNT(*)::text AS inativos_count
|
||||||
|
FROM (
|
||||||
|
SELECT c.id_cliente, c.cod_vendedor
|
||||||
|
FROM vw_clientes c
|
||||||
|
LEFT JOIN vw_pedidos_erp p
|
||||||
|
ON p.id_cliente = c.id_cliente
|
||||||
|
AND p.id_empresa = ${idEmpresa}
|
||||||
|
AND p.situa != 5
|
||||||
|
WHERE c.ativo = 1
|
||||||
|
${teamSqlFilter('c.cod_vendedor')}
|
||||||
|
GROUP BY c.id_cliente, c.cod_vendedor
|
||||||
|
HAVING MAX(p.dt_pedido) IS NULL
|
||||||
|
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
|
||||||
|
) inativos
|
||||||
|
GROUP BY inativos.cod_vendedor
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
LIMIT 3
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Resolve nomes de cliente e representante da fila (pedidos SAR só têm os códigos)
|
||||||
|
const repCods = [...new Set(approvalQueue.map((p) => p.codVendedor))];
|
||||||
|
const cliIds = [...new Set(approvalQueue.map((p) => p.idCliente))];
|
||||||
|
const [repNameRows, cliNameRows] = await Promise.all([
|
||||||
|
repCods.length
|
||||||
|
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||||
|
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
|
||||||
|
)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
cliIds.length
|
||||||
|
? prisma.$queryRawUnsafe<
|
||||||
|
{ id_cliente: number; nome: string | null; razao: string | null }[]
|
||||||
|
>(
|
||||||
|
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
|
||||||
|
)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
const repNameMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
|
||||||
|
const cliNameMap = new Map(
|
||||||
|
cliNameRows.map((c) => [Number(c.id_cliente), { nome: c.nome, razao: c.razao }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
|
||||||
|
id: o.id,
|
||||||
|
numPedSar: o.numPedSar,
|
||||||
|
idCliente: o.idCliente,
|
||||||
|
nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null,
|
||||||
|
razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null,
|
||||||
|
codVendedor: o.codVendedor,
|
||||||
|
nomeVendedor: repNameMap.get(o.codVendedor) ?? null,
|
||||||
|
situa: o.situa,
|
||||||
|
dtPedido: o.dtPedido.toISOString(),
|
||||||
|
total: String(o.total),
|
||||||
|
descontoPerc: String(o.descontoPerc),
|
||||||
|
obs: o.obs,
|
||||||
|
createdAt: o.createdAt.toISOString(),
|
||||||
|
fonte: 'sar' as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
approvalQueue: approvalQueue.map(mapPedido),
|
||||||
|
pedidosDia: {
|
||||||
|
count: Number(todayRows[0]?.count ?? 0),
|
||||||
|
total: Number(todayRows[0]?.total ?? 0),
|
||||||
|
countSemanaAnterior: Number(lastWeekRows[0]?.count ?? 0),
|
||||||
|
totalSemanaAnterior: Number(lastWeekRows[0]?.total ?? 0),
|
||||||
|
},
|
||||||
|
inativosPorRep: inativosPorRep.map((r) => ({
|
||||||
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome_vendedor ?? null,
|
||||||
|
inativosCount: parseInt(r.inativos_count, 10),
|
||||||
|
})),
|
||||||
|
syncedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/app/equipe/equipe.controller.ts
Normal file
13
apps/api/src/app/equipe/equipe.controller.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import type { EquipeResponse } from '@sar/api-interface';
|
||||||
|
import { EquipeService } from './equipe.service';
|
||||||
|
|
||||||
|
@Controller({ path: 'equipe' })
|
||||||
|
export class EquipeController {
|
||||||
|
constructor(private readonly equipe: EquipeService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listEquipe(): Promise<EquipeResponse> {
|
||||||
|
return this.equipe.listEquipe();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/equipe/equipe.module.ts
Normal file
9
apps/api/src/app/equipe/equipe.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { EquipeController } from './equipe.controller';
|
||||||
|
import { EquipeService } from './equipe.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [EquipeController],
|
||||||
|
providers: [EquipeService],
|
||||||
|
})
|
||||||
|
export class EquipeModule {}
|
||||||
113
apps/api/src/app/equipe/equipe.service.ts
Normal file
113
apps/api/src/app/equipe/equipe.service.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type { EquipeResponse } from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
|
function matrizEmpresa(idEmpresa: number): number {
|
||||||
|
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PedidosRepRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
pedidos: string;
|
||||||
|
faturamento: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetaRepRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
meta_valor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EquipeService {
|
||||||
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
async listEquipe(): Promise<EquipeResponse> {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role !== 'manager' && role !== 'admin') {
|
||||||
|
throw new ForbiddenException('Apenas gerentes podem consultar a equipe');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth() + 1;
|
||||||
|
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
|
||||||
|
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const [repRows, pedidosRows, metaRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<RepRow[]>(
|
||||||
|
`SELECT codigo AS cod_vendedor, nome FROM vw_representantes ORDER BY nome`,
|
||||||
|
),
|
||||||
|
prisma.$queryRawUnsafe<PedidosRepRow[]>(`
|
||||||
|
SELECT cod_vendedor,
|
||||||
|
COUNT(*)::text AS pedidos,
|
||||||
|
COALESCE(SUM(total), 0)::text AS faturamento
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY cod_vendedor
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<MetaRepRow[]>(`
|
||||||
|
SELECT cod_vendedor,
|
||||||
|
SUM(valor)::text AS meta_valor
|
||||||
|
FROM sar.vw_metas
|
||||||
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND ano = ${year}
|
||||||
|
AND mes = ${month}
|
||||||
|
AND TRIM(tipo) = 'GR'
|
||||||
|
GROUP BY cod_vendedor
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pedidosMap = new Map(
|
||||||
|
pedidosRows.map((r) => [
|
||||||
|
Number(r.cod_vendedor),
|
||||||
|
{ pedidos: Number(r.pedidos), faturamento: Number(r.faturamento) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const metaMap = new Map(metaRows.map((r) => [Number(r.cod_vendedor), Number(r.meta_valor)]));
|
||||||
|
|
||||||
|
// vw_representantes pode retornar duplicatas por cod_vendedor — deduplicar
|
||||||
|
const seenCods = new Set<number>();
|
||||||
|
const uniqueReps = repRows.filter((r) => {
|
||||||
|
const cod = Number(r.cod_vendedor);
|
||||||
|
if (seenCods.has(cod)) return false;
|
||||||
|
seenCods.add(cod);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const reps = uniqueReps.map((r) => {
|
||||||
|
const cod = Number(r.cod_vendedor);
|
||||||
|
const stats = pedidosMap.get(cod);
|
||||||
|
const meta = metaMap.get(cod) ?? 0;
|
||||||
|
const faturamento = stats?.faturamento ?? 0;
|
||||||
|
const pedidos = stats?.pedidos ?? 0;
|
||||||
|
return {
|
||||||
|
codVendedor: cod,
|
||||||
|
nomeVendedor: r.nome ?? null,
|
||||||
|
pedidosMes: pedidos,
|
||||||
|
faturamentoMes: faturamento,
|
||||||
|
ticketMedio: pedidos > 0 ? faturamento / pedidos : 0,
|
||||||
|
pctMeta: meta > 0 ? Math.round((faturamento / meta) * 100) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
reps,
|
||||||
|
totalReps: reps.length,
|
||||||
|
syncedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
48
apps/api/src/app/funil/funil.controller.ts
Normal file
48
apps/api/src/app/funil/funil.controller.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
CreateOportunidadeSchema,
|
||||||
|
UpdateOportunidadeSchema,
|
||||||
|
type FunilResponse,
|
||||||
|
type Oportunidade,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { FunilService } from './funil.service';
|
||||||
|
|
||||||
|
class CreateDto extends createZodDto(CreateOportunidadeSchema) {}
|
||||||
|
class UpdateDto extends createZodDto(UpdateOportunidadeSchema) {}
|
||||||
|
|
||||||
|
@Controller('funil')
|
||||||
|
export class FunilController {
|
||||||
|
constructor(private readonly funil: FunilService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listar(): Promise<FunilResponse> {
|
||||||
|
return this.funil.listar();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
criar(@Body() dto: CreateDto): Promise<Oportunidade> {
|
||||||
|
return this.funil.criar(CreateOportunidadeSchema.parse(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
atualizar(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateDto): Promise<Oportunidade> {
|
||||||
|
return this.funil.atualizar(id, UpdateOportunidadeSchema.parse(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
deletar(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.funil.deletar(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/funil/funil.module.ts
Normal file
9
apps/api/src/app/funil/funil.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FunilController } from './funil.controller';
|
||||||
|
import { FunilService } from './funil.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [FunilController],
|
||||||
|
providers: [FunilService],
|
||||||
|
})
|
||||||
|
export class FunilModule {}
|
||||||
141
apps/api/src/app/funil/funil.service.ts
Normal file
141
apps/api/src/app/funil/funil.service.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type {
|
||||||
|
CreateOportunidadeDto,
|
||||||
|
UpdateOportunidadeDto,
|
||||||
|
FunilResponse,
|
||||||
|
EtapaFunil,
|
||||||
|
Oportunidade,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
|
||||||
|
type OportRow = {
|
||||||
|
id: number;
|
||||||
|
idCliente: number | null;
|
||||||
|
nomeProspect: string | null;
|
||||||
|
empresaProspect: string | null;
|
||||||
|
titulo: string;
|
||||||
|
etapa: string;
|
||||||
|
valorEstimado: unknown;
|
||||||
|
observacoes: string | null;
|
||||||
|
idPedido: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PrismaCtx = {
|
||||||
|
oportunidade: {
|
||||||
|
findMany: (args: object) => Promise<OportRow[]>;
|
||||||
|
create: (args: object) => Promise<OportRow>;
|
||||||
|
update: (args: object) => Promise<OportRow>;
|
||||||
|
delete: (args: object) => Promise<void>;
|
||||||
|
};
|
||||||
|
$queryRawUnsafe: <T>(sql: string, ...args: unknown[]) => Promise<T[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClienteRow = { id_cliente: number; nome: string | null; razao: string | null };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FunilService {
|
||||||
|
constructor(private readonly cls: ClsService) {}
|
||||||
|
|
||||||
|
private ctx(): { prisma: PrismaCtx; idEmpresa: number; codVendedor: number } {
|
||||||
|
const prisma = this.cls.get('prisma') as PrismaCtx;
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa') as number;
|
||||||
|
const userId = this.cls.get('userId') as string | undefined;
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
return { prisma, idEmpresa, codVendedor };
|
||||||
|
}
|
||||||
|
|
||||||
|
private toOp(r: OportRow, nomeMap: Map<number, string>): Oportunidade {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
idCliente: r.idCliente,
|
||||||
|
nomeProspect: r.nomeProspect,
|
||||||
|
empresaProspect: r.empresaProspect,
|
||||||
|
titulo: r.titulo,
|
||||||
|
etapa: r.etapa as EtapaFunil,
|
||||||
|
valorEstimado: r.valorEstimado != null ? String(r.valorEstimado) : null,
|
||||||
|
observacoes: r.observacoes,
|
||||||
|
idPedido: r.idPedido,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
nomeCliente: r.idCliente ? (nomeMap.get(r.idCliente) ?? null) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listar(): Promise<FunilResponse> {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
|
||||||
|
const rows = await prisma.oportunidade.findMany({
|
||||||
|
where: { idEmpresa, codVendedor },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const idClientes = [
|
||||||
|
...new Set(rows.filter((r) => r.idCliente).map((r) => r.idCliente as number)),
|
||||||
|
];
|
||||||
|
const nomeMap = new Map<number, string>();
|
||||||
|
if (idClientes.length > 0) {
|
||||||
|
const ids = idClientes.join(',');
|
||||||
|
const clientes = await prisma.$queryRawUnsafe<ClienteRow>(
|
||||||
|
`SELECT id_cliente, TRIM(nome) AS nome, TRIM(razao) AS razao FROM vw_clientes WHERE id_cliente IN (${ids}) AND id_empresa = ${idEmpresa}`,
|
||||||
|
);
|
||||||
|
for (const c of clientes) {
|
||||||
|
nomeMap.set(c.id_cliente, c.razao ?? c.nome ?? `Cód. ${c.id_cliente}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byEtapa = (etapa: EtapaFunil) =>
|
||||||
|
rows.filter((r) => r.etapa === etapa).map((r) => this.toOp(r, nomeMap));
|
||||||
|
|
||||||
|
return {
|
||||||
|
lead: byEtapa('lead'),
|
||||||
|
proposta: byEtapa('proposta'),
|
||||||
|
negociacao: byEtapa('negociacao'),
|
||||||
|
ganho: byEtapa('ganho'),
|
||||||
|
perdido: byEtapa('perdido'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async criar(dto: CreateOportunidadeDto): Promise<Oportunidade> {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const row = await prisma.oportunidade.create({
|
||||||
|
data: {
|
||||||
|
idEmpresa,
|
||||||
|
codVendedor,
|
||||||
|
idCliente: dto.idCliente ?? null,
|
||||||
|
nomeProspect: dto.nomeProspect ?? null,
|
||||||
|
empresaProspect: dto.empresaProspect ?? null,
|
||||||
|
titulo: dto.titulo,
|
||||||
|
etapa: dto.etapa ?? 'lead',
|
||||||
|
valorEstimado: dto.valorEstimado ?? null,
|
||||||
|
observacoes: dto.observacoes ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.toOp(row, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
async atualizar(id: number, dto: UpdateOportunidadeDto): Promise<Oportunidade> {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const row = await prisma.oportunidade.update({
|
||||||
|
where: { id, idEmpresa, codVendedor },
|
||||||
|
data: {
|
||||||
|
...(dto.idCliente !== undefined && { idCliente: dto.idCliente }),
|
||||||
|
...(dto.nomeProspect !== undefined && { nomeProspect: dto.nomeProspect }),
|
||||||
|
...(dto.empresaProspect !== undefined && { empresaProspect: dto.empresaProspect }),
|
||||||
|
...(dto.titulo !== undefined && { titulo: dto.titulo }),
|
||||||
|
...(dto.etapa !== undefined && { etapa: dto.etapa }),
|
||||||
|
...(dto.valorEstimado !== undefined && { valorEstimado: dto.valorEstimado }),
|
||||||
|
...(dto.observacoes !== undefined && { observacoes: dto.observacoes }),
|
||||||
|
...(dto.idPedido !== undefined && { idPedido: dto.idPedido }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.toOp(row, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletar(id: number): Promise<void> {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
await prisma.oportunidade.delete({ where: { id, idEmpresa, codVendedor } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,22 +5,22 @@ import {
|
|||||||
HealthCheckService,
|
HealthCheckService,
|
||||||
MemoryHealthIndicator,
|
MemoryHealthIndicator,
|
||||||
} from '@nestjs/terminus';
|
} from '@nestjs/terminus';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
import { WorkspacePoolHealthIndicator } from './workspace-pool.health-indicator';
|
||||||
|
|
||||||
// CODING-RULES §20 (PGD-OBS-003):
|
// CODING-RULES §20 (PGD-OBS-003):
|
||||||
// /health/live → liveness só com memory.checkHeap(350MB).
|
// /health/live → liveness: memory.checkHeap(350MB).
|
||||||
// /health/ready → readiness pinga master-login + amostra LRU (K=3) dos pools
|
// /health/ready → readiness: heap + amostra LRU (K=3) do WorkspacePrismaPool.
|
||||||
// quentes do WorkspacePrismaPool + Valkey + BullMQ.
|
// Próximos: MasterLoginHealthIndicator, ValkeyHealthIndicator, BullMQHealthIndicator.
|
||||||
// NUNCA percorrer todos os workspaces (O(N) → false negative).
|
// NUNCA percorrer todos os workspaces (O(N)).
|
||||||
//
|
|
||||||
// Hoje o "ready" só checa heap, idêntico ao live. Quando master-login,
|
|
||||||
// WorkspacePrismaPool, Valkey e BullMQ entrarem, cada um adiciona seu indicator
|
|
||||||
// aqui — sem nunca virar O(N) sobre workspaces.
|
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Controller({ path: 'health' })
|
@Controller({ path: 'health' })
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly health: HealthCheckService,
|
private readonly health: HealthCheckService,
|
||||||
private readonly memory: MemoryHealthIndicator,
|
private readonly memory: MemoryHealthIndicator,
|
||||||
|
private readonly workspacePool: WorkspacePoolHealthIndicator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('live')
|
@Get('live')
|
||||||
@@ -32,11 +32,9 @@ export class HealthController {
|
|||||||
@Get('ready')
|
@Get('ready')
|
||||||
@HealthCheck()
|
@HealthCheck()
|
||||||
ready(): Promise<HealthCheckResult> {
|
ready(): Promise<HealthCheckResult> {
|
||||||
// Skeleton: por enquanto idêntico ao live. Próximas frentes:
|
return this.health.check([
|
||||||
// - MasterLoginHealthIndicator (obrigatório)
|
() => this.memory.checkHeap('heap', 350 * 1024 * 1024),
|
||||||
// - WorkspacePoolLruHealthIndicator (K=3 amostra)
|
() => this.workspacePool.check('workspace_pool', 3),
|
||||||
// - ValkeyHealthIndicator
|
]);
|
||||||
// - BullMQHealthIndicator
|
|
||||||
return this.health.check([() => this.memory.checkHeap('heap', 350 * 1024 * 1024)]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TerminusModule } from '@nestjs/terminus';
|
import { TerminusModule } from '@nestjs/terminus';
|
||||||
|
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
import { WorkspacePoolHealthIndicator } from './workspace-pool.health-indicator';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TerminusModule],
|
imports: [TerminusModule, WorkspaceModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
providers: [WorkspacePoolHealthIndicator],
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
|||||||
35
apps/api/src/app/health/workspace-pool.health-indicator.ts
Normal file
35
apps/api/src/app/health/workspace-pool.health-indicator.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HealthIndicatorResult, HealthIndicator, HealthCheckError } from '@nestjs/terminus';
|
||||||
|
import { WorkspacePrismaPool } from '../workspace/workspace-prisma-pool.service';
|
||||||
|
|
||||||
|
// Amostra os K workspaces mais quentes do LRU — nunca O(N) sobre todos os workspaces.
|
||||||
|
// CODING-RULES §20 (PGD-OBS-003): readiness não percorre workspaces individualmente.
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspacePoolHealthIndicator extends HealthIndicator {
|
||||||
|
constructor(private readonly pool: WorkspacePrismaPool) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(key = 'workspace_pool', k = 3): Promise<HealthIndicatorResult> {
|
||||||
|
const results = await this.pool.health(k);
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return this.getStatus(key, true, { active: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
const isHealthy = failed.length === 0;
|
||||||
|
const detail = {
|
||||||
|
active: results.length,
|
||||||
|
healthy: results.length - failed.length,
|
||||||
|
...(failed.length > 0 && { failed: failed.map((r) => r.workspaceId) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isHealthy) {
|
||||||
|
throw new HealthCheckError(`${key} degradado`, this.getStatus(key, false, detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getStatus(key, true, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/api/src/app/notifications/notifications.controller.ts
Normal file
34
apps/api/src/app/notifications/notifications.controller.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
SubscribePayloadSchema,
|
||||||
|
UnsubscribePayloadSchema,
|
||||||
|
type SubscribePayload,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/jwt.types';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
|
||||||
|
class UnsubscribeDto extends createZodDto(UnsubscribePayloadSchema) {}
|
||||||
|
|
||||||
|
@Controller('notifications')
|
||||||
|
export class NotificationsController {
|
||||||
|
constructor(private readonly svc: NotificationsService) {}
|
||||||
|
|
||||||
|
@Post('subscribe')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async subscribe(@Req() req: AuthenticatedRequest, @Body() body: SubscribeDto): Promise<void> {
|
||||||
|
await this.svc.subscribe(req.user.sub, req.user.role, body as unknown as SubscribePayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('unsubscribe')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise<void> {
|
||||||
|
await this.svc.unsubscribe(req.user.sub, body.endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('pending-count')
|
||||||
|
async pendingCount(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.svc.pendingCount(req.user.sub, req.user.role);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {}
|
||||||
97
apps/api/src/app/notifications/notifications.service.ts
Normal file
97
apps/api/src/app/notifications/notifications.service.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type { SubscribePayload, PendingCountResponse } from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { PushService, type PushPayload } from './push.service';
|
||||||
|
|
||||||
|
// Situa: 1=Pendente Aprovação
|
||||||
|
const SITUA_PENDENTE = 1;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsService {
|
||||||
|
constructor(
|
||||||
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
|
private readonly push: PushService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async subscribe(userId: string, role: string, dto: SubscribePayload): Promise<void> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : null;
|
||||||
|
|
||||||
|
await prisma.pushSubscription.upsert({
|
||||||
|
where: { endpoint: dto.endpoint },
|
||||||
|
update: {
|
||||||
|
codVendedor,
|
||||||
|
idEmpresa,
|
||||||
|
role,
|
||||||
|
p256dh: dto.keys.p256dh,
|
||||||
|
auth: dto.keys.auth,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
codVendedor,
|
||||||
|
idEmpresa,
|
||||||
|
role,
|
||||||
|
endpoint: dto.endpoint,
|
||||||
|
p256dh: dto.keys.p256dh,
|
||||||
|
auth: dto.keys.auth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escopado ao dono: só remove subscription do próprio usuário — quem souber o
|
||||||
|
// endpoint de terceiro não consegue desregistrá-lo.
|
||||||
|
async unsubscribe(userId: string, endpoint: string): Promise<void> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : null;
|
||||||
|
|
||||||
|
await prisma.pushSubscription.deleteMany({ where: { endpoint, idEmpresa, codVendedor } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
if (role === 'supervisor' || role === 'manager' || role === 'admin') {
|
||||||
|
const count = await prisma.pedido.count({
|
||||||
|
where: { situa: SITUA_PENDENTE, idEmpresa },
|
||||||
|
});
|
||||||
|
return { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envia push para todos os supervisores/managers/admin da empresa.
|
||||||
|
async notifySupervisors(payload: PushPayload): Promise<void> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) return;
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
const subs = await prisma.pushSubscription.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa,
|
||||||
|
role: { in: ['supervisor', 'manager', 'admin'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envia push para um codVendedor específico (todos os dispositivos registrados).
|
||||||
|
async notifyUser(userId: string, payload: PushPayload): Promise<void> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) return;
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
|
const subs = await prisma.pushSubscription.findMany({
|
||||||
|
where: { idEmpresa, codVendedor },
|
||||||
|
});
|
||||||
|
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
|
||||||
|
}
|
||||||
|
}
|
||||||
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
apps/api/src/app/orders/orders.controller.ts
Normal file
107
apps/api/src/app/orders/orders.controller.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
AprovarPedidoSchema,
|
||||||
|
CreatePedidoSchema,
|
||||||
|
PedidoErpConsultaQuerySchema,
|
||||||
|
PedidoListQuerySchema,
|
||||||
|
RecusarPedidoSchema,
|
||||||
|
type AprovarPedido,
|
||||||
|
type CreatePedido,
|
||||||
|
type PedidoDetail,
|
||||||
|
type PedidoErpConsultaQuery,
|
||||||
|
type PedidoErpConsultaResponse,
|
||||||
|
type PedidoListQuery,
|
||||||
|
type PedidoListResponse,
|
||||||
|
type RecusarPedido,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
|
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
||||||
|
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
||||||
|
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
||||||
|
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
||||||
|
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
|
||||||
|
|
||||||
|
@Controller({ path: 'orders' })
|
||||||
|
export class OrdersController {
|
||||||
|
constructor(
|
||||||
|
private readonly orders: OrdersService,
|
||||||
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
|
||||||
|
const parsed = PedidoListQuerySchema.parse(query) as PedidoListQuery;
|
||||||
|
return this.orders.list(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(201)
|
||||||
|
create(@Body() body: CreatePedidoDto): Promise<PedidoDetail> {
|
||||||
|
const parsed = CreatePedidoSchema.parse(body) as CreatePedido;
|
||||||
|
return this.orders.create(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/transmit')
|
||||||
|
transmit(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||||
|
return this.orders.transmit(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/approve')
|
||||||
|
approve(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() body: AprovarPedidoDto,
|
||||||
|
): Promise<PedidoDetail> {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
|
||||||
|
const parsed = AprovarPedidoSchema.parse(body) as AprovarPedido;
|
||||||
|
return this.orders.approve(id, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/reject')
|
||||||
|
reject(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() body: RecusarPedidoDto,
|
||||||
|
): Promise<PedidoDetail> {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
|
||||||
|
const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido;
|
||||||
|
return this.orders.reject(id, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/cancel')
|
||||||
|
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||||
|
return this.orders.cancel(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('erp/:idPedido')
|
||||||
|
findOneErp(@Param('idPedido', ParseIntPipe) idPedido: number): Promise<PedidoDetail> {
|
||||||
|
return this.orders.findOneErp(idPedido);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('erp-consulta')
|
||||||
|
listErpConsulta(@Query() query: PedidoErpConsultaQueryDto): Promise<PedidoErpConsultaResponse> {
|
||||||
|
const parsed = PedidoErpConsultaQuerySchema.parse(query) as PedidoErpConsultaQuery;
|
||||||
|
return this.orders.listErpConsulta(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||||
|
return this.orders.findOne(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {}
|
||||||
1040
apps/api/src/app/orders/orders.service.ts
Normal file
1040
apps/api/src/app/orders/orders.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { Controller, Get } from '@nestjs/common';
|
|||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type { PingResponse } from '@sar/api-interface';
|
import type { PingResponse } from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
|
||||||
// Endpoint de verificação de fundação:
|
// Endpoint de verificação de fundação:
|
||||||
// - confirma que CLS está populando workspaceId + requestId;
|
// - confirma que CLS está populando workspaceId + requestId;
|
||||||
@@ -9,6 +10,7 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
|||||||
// - usado pela Web (Frente B) para validar conectividade real.
|
// - usado pela Web (Frente B) para validar conectividade real.
|
||||||
// Contrato: @sar/api-interface · PingResponseSchema (zod).
|
// Contrato: @sar/api-interface · PingResponseSchema (zod).
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Controller({ path: 'ping' })
|
@Controller({ path: 'ping' })
|
||||||
export class PingController {
|
export class PingController {
|
||||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
@@ -19,7 +21,7 @@ export class PingController {
|
|||||||
status: 'ok',
|
status: 'ok',
|
||||||
service: 'sar-api',
|
service: 'sar-api',
|
||||||
version: process.env['npm_package_version'] ?? '0.1.0',
|
version: process.env['npm_package_version'] ?? '0.1.0',
|
||||||
workspaceId: this.cls.get('workspaceId'),
|
idEmpresa: this.cls.get('idEmpresa'),
|
||||||
requestId: this.cls.get('requestId'),
|
requestId: this.cls.get('requestId'),
|
||||||
uptimeSeconds: Math.round(process.uptime()),
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
now: new Date().toISOString(),
|
now: new Date().toISOString(),
|
||||||
|
|||||||
116
apps/api/src/app/politicas/politicas.controller.ts
Normal file
116
apps/api/src/app/politicas/politicas.controller.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
UpsertDescontoBodySchema,
|
||||||
|
CreatePromocaoBodySchema,
|
||||||
|
UpdatePromocaoBodySchema,
|
||||||
|
CreateRegraDescontoBodySchema,
|
||||||
|
UpdateRegraDescontoBodySchema,
|
||||||
|
type UpsertDescontoBody,
|
||||||
|
type CreatePromocaoBody,
|
||||||
|
type UpdatePromocaoBody,
|
||||||
|
type CreateRegraDescontoBody,
|
||||||
|
type UpdateRegraDescontoBody,
|
||||||
|
type AlcadaDescontosResponse,
|
||||||
|
type PromocoesResponse,
|
||||||
|
type PromocoesVigentesResponse,
|
||||||
|
type RegrasDescontoResponse,
|
||||||
|
type RegrasVigentesResponse,
|
||||||
|
type GruposProdutoResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { PoliticasService } from './politicas.service';
|
||||||
|
|
||||||
|
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
|
||||||
|
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
|
||||||
|
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
|
||||||
|
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
|
||||||
|
class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema) {}
|
||||||
|
|
||||||
|
@Controller({ path: 'politicas' })
|
||||||
|
export class PoliticasController {
|
||||||
|
constructor(private readonly politicas: PoliticasService) {}
|
||||||
|
|
||||||
|
@Get('descontos')
|
||||||
|
listDescontos(): Promise<AlcadaDescontosResponse> {
|
||||||
|
return this.politicas.listDescontos();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('descontos')
|
||||||
|
@HttpCode(200)
|
||||||
|
upsertDesconto(@Body() body: UpsertDescontoDto): Promise<void> {
|
||||||
|
return this.politicas.upsertDesconto(body as unknown as UpsertDescontoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('promocoes')
|
||||||
|
listPromocoes(): Promise<PromocoesResponse> {
|
||||||
|
return this.politicas.listPromocoes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('promocoes/vigentes')
|
||||||
|
listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||||
|
return this.politicas.listPromocoesVigentes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('promocoes')
|
||||||
|
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
|
||||||
|
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('promocoes/:id')
|
||||||
|
updatePromocao(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() body: UpdatePromocaoDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.politicas.updatePromocao(id, body as unknown as UpdatePromocaoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('promocoes/:id')
|
||||||
|
@HttpCode(204)
|
||||||
|
deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.politicas.deletePromocao(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('regras')
|
||||||
|
listRegras(): Promise<RegrasDescontoResponse> {
|
||||||
|
return this.politicas.listRegras();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('regras/vigentes')
|
||||||
|
listRegrasVigentes(): Promise<RegrasVigentesResponse> {
|
||||||
|
return this.politicas.listRegrasVigentes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('regras')
|
||||||
|
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ id: number }> {
|
||||||
|
return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('regras/:id')
|
||||||
|
updateRegra(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() body: UpdateRegraDescontoDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.politicas.updateRegra(id, body as unknown as UpdateRegraDescontoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('regras/:id')
|
||||||
|
@HttpCode(204)
|
||||||
|
deleteRegra(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.politicas.deleteRegra(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('grupos-produto')
|
||||||
|
listGruposProduto(): Promise<GruposProdutoResponse> {
|
||||||
|
return this.politicas.listGruposProduto();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/politicas/politicas.module.ts
Normal file
9
apps/api/src/app/politicas/politicas.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PoliticasController } from './politicas.controller';
|
||||||
|
import { PoliticasService } from './politicas.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PoliticasController],
|
||||||
|
providers: [PoliticasService],
|
||||||
|
})
|
||||||
|
export class PoliticasModule {}
|
||||||
382
apps/api/src/app/politicas/politicas.service.ts
Normal file
382
apps/api/src/app/politicas/politicas.service.ts
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type {
|
||||||
|
AlcadaDescontosResponse,
|
||||||
|
UpsertDescontoBody,
|
||||||
|
PromocoesResponse,
|
||||||
|
CreatePromocaoBody,
|
||||||
|
UpdatePromocaoBody,
|
||||||
|
RegrasDescontoResponse,
|
||||||
|
CreateRegraDescontoBody,
|
||||||
|
UpdateRegraDescontoBody,
|
||||||
|
RegrasVigentesResponse,
|
||||||
|
PromocoesVigentesResponse,
|
||||||
|
GruposProdutoResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PoliticasService {
|
||||||
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
private assertManager(): void {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role !== 'manager' && role !== 'admin') {
|
||||||
|
throw new ForbiddenException('Apenas gerentes podem gerenciar políticas comerciais');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDescontos(): Promise<AlcadaDescontosResponse> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
const rows = await prisma.alcadaDesconto.findMany({
|
||||||
|
where: { idEmpresa },
|
||||||
|
orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Busca nomes dos representantes
|
||||||
|
const codigos = [...new Set(rows.map((r) => r.codVendedor))];
|
||||||
|
const repRows =
|
||||||
|
codigos.length > 0
|
||||||
|
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||||
|
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
descontos: rows.map((r) => ({
|
||||||
|
codVendedor: r.codVendedor,
|
||||||
|
codGrupo: r.codGrupo,
|
||||||
|
limitePerc: Number(r.limitePerc),
|
||||||
|
nomeVendedor: repNameMap.get(r.codVendedor) ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertDesconto(body: UpsertDescontoBody): Promise<void> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
await prisma.alcadaDesconto.upsert({
|
||||||
|
where: {
|
||||||
|
codVendedor_idEmpresa_codGrupo: {
|
||||||
|
codVendedor: body.codVendedor,
|
||||||
|
idEmpresa,
|
||||||
|
codGrupo: body.codGrupo ?? 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
codVendedor: body.codVendedor,
|
||||||
|
idEmpresa,
|
||||||
|
codGrupo: body.codGrupo ?? 0,
|
||||||
|
limitePerc: body.limitePerc,
|
||||||
|
},
|
||||||
|
update: { limitePerc: body.limitePerc },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPromocoes(): Promise<PromocoesResponse> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
const rows = await prisma.promocao.findMany({
|
||||||
|
where: { idEmpresa },
|
||||||
|
orderBy: [{ dataFim: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promocoes: rows.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
descricao: p.descricao,
|
||||||
|
codProduto: p.codProduto,
|
||||||
|
grpProd: p.grpProd,
|
||||||
|
descPct: Number(p.descPct),
|
||||||
|
dataInicio: p.dataInicio.toISOString().slice(0, 10),
|
||||||
|
dataFim: p.dataFim.toISOString().slice(0, 10),
|
||||||
|
ativa: p.ativa,
|
||||||
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
createdBy: p.createdBy,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPromocao(body: CreatePromocaoBody): Promise<{ id: number }> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const userId = this.cls.get('userId') ?? 'system';
|
||||||
|
|
||||||
|
const p = await prisma.promocao.create({
|
||||||
|
data: {
|
||||||
|
idEmpresa,
|
||||||
|
descricao: body.descricao,
|
||||||
|
codProduto: body.codProduto ?? null,
|
||||||
|
grpProd: body.grpProd ?? null,
|
||||||
|
descPct: body.descPct,
|
||||||
|
dataInicio: new Date(body.dataInicio),
|
||||||
|
dataFim: new Date(body.dataFim),
|
||||||
|
createdBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: p.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
const exists = await prisma.promocao.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!exists) throw new NotFoundException('Promoção não encontrada');
|
||||||
|
|
||||||
|
await prisma.promocao.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(body.descricao !== undefined && { descricao: body.descricao }),
|
||||||
|
...(body.codProduto !== undefined && { codProduto: body.codProduto }),
|
||||||
|
...(body.grpProd !== undefined && { grpProd: body.grpProd }),
|
||||||
|
...(body.descPct !== undefined && { descPct: body.descPct }),
|
||||||
|
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
|
||||||
|
...(body.dataFim !== undefined && { dataFim: new Date(body.dataFim) }),
|
||||||
|
...(body.ativa !== undefined && { ativa: body.ativa }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePromocao(id: number): Promise<void> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
const exists = await prisma.promocao.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!exists) throw new NotFoundException('Promoção não encontrada');
|
||||||
|
|
||||||
|
await prisma.promocao.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Regras de Desconto ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Grupos/subgrupos distintos de vw_produtos (empresa matriz), para combos e
|
||||||
|
// resolução de nomes na listagem — UI sempre mostra o nome, nunca só o código.
|
||||||
|
private async loadGruposProduto(): Promise<
|
||||||
|
{
|
||||||
|
cod_grupo: number | null;
|
||||||
|
grupo: string | null;
|
||||||
|
cod_subgrupo: number | null;
|
||||||
|
subgrupo: string | null;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
|
return prisma.$queryRawUnsafe(
|
||||||
|
`SELECT DISTINCT cod_grupo, TRIM(grupo) AS grupo, cod_subgrupo, TRIM(subgrupo) AS subgrupo
|
||||||
|
FROM vw_produtos
|
||||||
|
WHERE id_empresa = ${idMatriz}
|
||||||
|
AND (cod_grupo IS NOT NULL OR cod_subgrupo IS NOT NULL)
|
||||||
|
ORDER BY grupo, subgrupo`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGruposProduto(): Promise<GruposProdutoResponse> {
|
||||||
|
this.assertManager();
|
||||||
|
const rows = await this.loadGruposProduto();
|
||||||
|
return {
|
||||||
|
grupos: rows.map((r) => ({
|
||||||
|
codGrupo: r.cod_grupo != null ? Number(r.cod_grupo) : null,
|
||||||
|
grupo: r.grupo,
|
||||||
|
codSubgrupo: r.cod_subgrupo != null ? Number(r.cod_subgrupo) : null,
|
||||||
|
subgrupo: r.subgrupo,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRegras(): Promise<RegrasDescontoResponse> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
const rows = await prisma.regraDesconto.findMany({
|
||||||
|
where: { idEmpresa },
|
||||||
|
orderBy: [{ dataFim: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const codigos = [
|
||||||
|
...new Set(rows.map((r) => r.codVendedor).filter((c): c is number => c != null)),
|
||||||
|
];
|
||||||
|
const repRows =
|
||||||
|
codigos.length > 0
|
||||||
|
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||||
|
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
|
||||||
|
|
||||||
|
const grupos = rows.some((r) => r.codGrupo != null || r.codSubgrupo != null)
|
||||||
|
? await this.loadGruposProduto()
|
||||||
|
: [];
|
||||||
|
const grupoNameMap = new Map(
|
||||||
|
grupos.filter((g) => g.cod_grupo != null).map((g) => [Number(g.cod_grupo), g.grupo]),
|
||||||
|
);
|
||||||
|
const subgrupoNameMap = new Map(
|
||||||
|
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
regras: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
descricao: r.descricao,
|
||||||
|
codGrupo: r.codGrupo,
|
||||||
|
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||||
|
codSubgrupo: r.codSubgrupo,
|
||||||
|
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
|
||||||
|
codVendedor: r.codVendedor,
|
||||||
|
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
|
||||||
|
descPct: Number(r.descPct),
|
||||||
|
dataInicio: r.dataInicio.toISOString().slice(0, 10),
|
||||||
|
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||||
|
ativa: r.ativa,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
createdBy: r.createdBy,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const userId = this.cls.get('userId') ?? 'system';
|
||||||
|
|
||||||
|
const r = await prisma.regraDesconto.create({
|
||||||
|
data: {
|
||||||
|
idEmpresa,
|
||||||
|
descricao: body.descricao,
|
||||||
|
codGrupo: body.codGrupo ?? null,
|
||||||
|
codSubgrupo: body.codSubgrupo ?? null,
|
||||||
|
codVendedor: body.codVendedor ?? null,
|
||||||
|
descPct: body.descPct,
|
||||||
|
dataInicio: new Date(body.dataInicio),
|
||||||
|
dataFim: new Date(body.dataFim),
|
||||||
|
createdBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: r.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise<void> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
const exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
|
||||||
|
|
||||||
|
await prisma.regraDesconto.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(body.descricao !== undefined && { descricao: body.descricao }),
|
||||||
|
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
|
||||||
|
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
|
||||||
|
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
|
||||||
|
...(body.descPct !== undefined && { descPct: body.descPct }),
|
||||||
|
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
|
||||||
|
...(body.dataFim !== undefined && { dataFim: new Date(body.dataFim) }),
|
||||||
|
...(body.ativa !== undefined && { ativa: body.ativa }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRegra(id: number): Promise<void> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
const exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
|
||||||
|
|
||||||
|
await prisma.regraDesconto.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
|
||||||
|
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
|
||||||
|
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
|
const hoje = new Date();
|
||||||
|
const rows = await prisma.promocao.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||||
|
ativa: true,
|
||||||
|
dataInicio: { lte: hoje },
|
||||||
|
dataFim: { gte: hoje },
|
||||||
|
},
|
||||||
|
orderBy: [{ descPct: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promocoes: rows.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
descricao: p.descricao,
|
||||||
|
codProduto: p.codProduto,
|
||||||
|
grpProd: p.grpProd,
|
||||||
|
descPct: Number(p.descPct),
|
||||||
|
dataFim: p.dataFim.toISOString().slice(0, 10),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regras vigentes hoje para o usuário logado. Rep recebe só as regras que o
|
||||||
|
// alcançam (cod_vendedor nulo = todos, ou o próprio código); gerente vê todas.
|
||||||
|
async listRegrasVigentes(): Promise<RegrasVigentesResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
const codVendedor = parseInt(this.cls.get('userId') ?? '0', 10);
|
||||||
|
|
||||||
|
const hoje = new Date();
|
||||||
|
const rows = await prisma.regraDesconto.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||||
|
ativa: true,
|
||||||
|
dataInicio: { lte: hoje },
|
||||||
|
dataFim: { gte: hoje },
|
||||||
|
...(role === 'rep' ? { OR: [{ codVendedor: null }, { codVendedor }] } : {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ descPct: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
regras: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
descricao: r.descricao,
|
||||||
|
codGrupo: r.codGrupo,
|
||||||
|
codSubgrupo: r.codSubgrupo,
|
||||||
|
descPct: Number(r.descPct),
|
||||||
|
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/api/src/app/reports/reports.controller.ts
Normal file
33
apps/api/src/app/reports/reports.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
ReportMetaQuerySchema,
|
||||||
|
ReportAbcQuerySchema,
|
||||||
|
type ReportMetaResponse,
|
||||||
|
type ReportCarteiraResponse,
|
||||||
|
type ReportAbcResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
class ReportMetaQueryDto extends createZodDto(ReportMetaQuerySchema) {}
|
||||||
|
class ReportAbcQueryDto extends createZodDto(ReportAbcQuerySchema) {}
|
||||||
|
|
||||||
|
@Controller({ path: 'reports' })
|
||||||
|
export class ReportsController {
|
||||||
|
constructor(private readonly reports: ReportsService) {}
|
||||||
|
|
||||||
|
@Get('meta')
|
||||||
|
meta(@Query() query: ReportMetaQueryDto): Promise<ReportMetaResponse> {
|
||||||
|
return this.reports.metaVsRealizado(ReportMetaQuerySchema.parse(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('carteira')
|
||||||
|
carteira(): Promise<ReportCarteiraResponse> {
|
||||||
|
return this.reports.carteira();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('abc')
|
||||||
|
abc(@Query() query: ReportAbcQueryDto): Promise<ReportAbcResponse> {
|
||||||
|
return this.reports.abcProdutos(ReportAbcQuerySchema.parse(query));
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/reports/reports.module.ts
Normal file
9
apps/api/src/app/reports/reports.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ReportsController } from './reports.controller';
|
||||||
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ReportsController],
|
||||||
|
providers: [ReportsService],
|
||||||
|
})
|
||||||
|
export class ReportsModule {}
|
||||||
264
apps/api/src/app/reports/reports.service.ts
Normal file
264
apps/api/src/app/reports/reports.service.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type {
|
||||||
|
ReportMetaQuery,
|
||||||
|
ReportMetaResponse,
|
||||||
|
ReportCarteiraResponse,
|
||||||
|
ReportAbcQuery,
|
||||||
|
ReportAbcResponse,
|
||||||
|
AbcProduto,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||||
|
|
||||||
|
function d(v: unknown): string {
|
||||||
|
return v != null ? String(v) : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function n(v: unknown): number {
|
||||||
|
return v != null ? Number(v) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReportsService {
|
||||||
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
async metaVsRealizado(query: ReportMetaQuery): Promise<ReportMetaResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
const { mes, ano } = query;
|
||||||
|
|
||||||
|
interface RealizadoRow {
|
||||||
|
realizado: unknown;
|
||||||
|
comissao: unknown;
|
||||||
|
total_pedidos: unknown;
|
||||||
|
}
|
||||||
|
const [realizadoRows, metaRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<RealizadoRow[]>(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(total), 0) AS realizado,
|
||||||
|
COALESCE(SUM(comissao), 0) AS comissao,
|
||||||
|
COUNT(id) AS total_pedidos
|
||||||
|
FROM sar.pedidos
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND cod_vendedor = ${codVendedor}
|
||||||
|
AND situa IN (2, 4)
|
||||||
|
AND EXTRACT(MONTH FROM dt_pedido) = ${mes}
|
||||||
|
AND EXTRACT(YEAR FROM dt_pedido) = ${ano}
|
||||||
|
`),
|
||||||
|
prisma.metaRepresentante.findUnique({
|
||||||
|
where: { codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano, mes } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const realizado = n(realizadoRows[0]?.realizado);
|
||||||
|
const comissao = n(realizadoRows[0]?.comissao);
|
||||||
|
const totalPedidos = n(realizadoRows[0]?.total_pedidos);
|
||||||
|
const meta = metaRows ? Number(metaRows.metaValor) : 0;
|
||||||
|
const percentual = meta > 0 ? (realizado / meta) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mes,
|
||||||
|
ano,
|
||||||
|
realizado: realizado.toFixed(2),
|
||||||
|
meta: meta.toFixed(2),
|
||||||
|
percentual: percentual.toFixed(2),
|
||||||
|
comissaoEstimada: comissao.toFixed(2),
|
||||||
|
totalPedidos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async carteira(): Promise<ReportCarteiraResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
|
// Rep vê a própria carteira; supervisor vê as carteiras da equipe
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null;
|
||||||
|
const vendCond = (col: string) =>
|
||||||
|
team ? `${col} IN (${team.join(',')})` : `${col} = ${codVendedor}`;
|
||||||
|
|
||||||
|
interface CarteiraRow {
|
||||||
|
id_cliente: unknown;
|
||||||
|
nome: string | null;
|
||||||
|
razao: string | null;
|
||||||
|
ativo: unknown;
|
||||||
|
ultimo_pedido: string | null;
|
||||||
|
dias_sem_pedido: unknown;
|
||||||
|
total_pedidos: unknown;
|
||||||
|
ticket_medio: unknown;
|
||||||
|
faturamento_total: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
|
||||||
|
WITH ultimos AS (
|
||||||
|
SELECT
|
||||||
|
id_cliente,
|
||||||
|
MAX(dt_pedido) AS ultimo_pedido,
|
||||||
|
COUNT(*) AS total_pedidos,
|
||||||
|
CASE WHEN COUNT(*) > 0
|
||||||
|
THEN SUM(total)::numeric / COUNT(*)
|
||||||
|
ELSE 0
|
||||||
|
END AS ticket_medio,
|
||||||
|
COALESCE(SUM(total), 0)::numeric AS faturamento_total
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND ${vendCond('cod_vendedor')}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
GROUP BY id_cliente
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.id_cliente,
|
||||||
|
TRIM(c.nome) AS nome,
|
||||||
|
TRIM(c.razao) AS razao,
|
||||||
|
c.ativo,
|
||||||
|
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
|
||||||
|
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
|
||||||
|
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
|
||||||
|
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio,
|
||||||
|
COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
|
||||||
|
FROM vw_clientes c
|
||||||
|
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
|
||||||
|
WHERE ${vendCond('c.cod_vendedor')}
|
||||||
|
AND c.ativo = 1
|
||||||
|
ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Deduplica por id_cliente — vw_clientes pode retornar o mesmo cliente em mais de uma empresa.
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const unique = rows.filter((r) => {
|
||||||
|
const id = n(r.id_cliente);
|
||||||
|
if (seen.has(id)) return false;
|
||||||
|
seen.add(id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const faturamentoRepTotal = unique.reduce((acc, r) => acc + n(r.faturamento_total), 0);
|
||||||
|
|
||||||
|
const data = unique.map((r) => {
|
||||||
|
const fat = n(r.faturamento_total);
|
||||||
|
return {
|
||||||
|
idCliente: n(r.id_cliente),
|
||||||
|
nome: r.nome ?? null,
|
||||||
|
razao: r.razao ?? null,
|
||||||
|
ativo: n(r.ativo),
|
||||||
|
ultimoPedido: r.ultimo_pedido ?? null,
|
||||||
|
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
|
||||||
|
totalPedidos: n(r.total_pedidos),
|
||||||
|
ticketMedio: d(r.ticket_medio),
|
||||||
|
faturamentoTotal: fat.toFixed(2),
|
||||||
|
participacaoPct:
|
||||||
|
faturamentoRepTotal > 0 ? Math.round((fat / faturamentoRepTotal) * 1000) / 10 : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const inativos30 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 30).length;
|
||||||
|
const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length;
|
||||||
|
const semPedido = data.filter((c) => c.totalPedidos === 0).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: data.length,
|
||||||
|
inativos30,
|
||||||
|
inativos60,
|
||||||
|
semPedido,
|
||||||
|
faturamentoRepTotal: faturamentoRepTotal.toFixed(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async abcProdutos(query: ReportAbcQuery): Promise<ReportAbcResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
const { mes, ano } = query;
|
||||||
|
|
||||||
|
const mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : '';
|
||||||
|
const anoFilter = ano != null ? `AND EXTRACT(YEAR FROM p.dt_pedido) = ${ano}` : '';
|
||||||
|
|
||||||
|
// Rep vê os próprios pedidos; supervisor vê os da equipe
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null;
|
||||||
|
const vendCond = team
|
||||||
|
? `p.cod_vendedor IN (${team.join(',')})`
|
||||||
|
: `p.cod_vendedor = ${codVendedor}`;
|
||||||
|
|
||||||
|
interface AbcRow {
|
||||||
|
cod_produto: string | null;
|
||||||
|
desc_produto: string | null;
|
||||||
|
total_faturado: unknown;
|
||||||
|
qtd_vendida: unknown;
|
||||||
|
desconto_medio: unknown;
|
||||||
|
comissao_total: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<AbcRow[]>(`
|
||||||
|
SELECT
|
||||||
|
pi.cod_produto,
|
||||||
|
pi.desc_produto,
|
||||||
|
SUM(pi.total) AS total_faturado,
|
||||||
|
SUM(pi.qtd) AS qtd_vendida,
|
||||||
|
AVG(pi.desconto_perc) AS desconto_medio,
|
||||||
|
SUM(pi.comissao) AS comissao_total
|
||||||
|
FROM sar.pedido_itens pi
|
||||||
|
JOIN sar.pedidos p ON p.id = pi.id_pedido
|
||||||
|
WHERE p.id_empresa = ${idEmpresa}
|
||||||
|
AND ${vendCond}
|
||||||
|
AND p.situa NOT IN (0, 3)
|
||||||
|
${mesFilter}
|
||||||
|
${anoFilter}
|
||||||
|
GROUP BY pi.cod_produto, pi.desc_produto
|
||||||
|
ORDER BY total_faturado DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const totalGeral = rows.reduce((s, r) => s + n(r.total_faturado), 0);
|
||||||
|
|
||||||
|
let acumulado = 0;
|
||||||
|
const data: AbcProduto[] = rows.map((r) => {
|
||||||
|
const total = n(r.total_faturado);
|
||||||
|
const participacao = totalGeral > 0 ? (total / totalGeral) * 100 : 0;
|
||||||
|
acumulado += participacao;
|
||||||
|
const curva: 'A' | 'B' | 'C' = acumulado <= 80 ? 'A' : acumulado <= 95 ? 'B' : 'C';
|
||||||
|
return {
|
||||||
|
codProduto: r.cod_produto ?? null,
|
||||||
|
descProduto: r.desc_produto ?? '(sem descrição)',
|
||||||
|
totalFaturado: total.toFixed(2),
|
||||||
|
qtdVendida: d(r.qtd_vendida),
|
||||||
|
participacao: participacao.toFixed(2),
|
||||||
|
curva,
|
||||||
|
descontoMedio: n(r.desconto_medio).toFixed(2),
|
||||||
|
comissaoTotal: n(r.comissao_total).toFixed(2),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const meses = [
|
||||||
|
'Jan',
|
||||||
|
'Fev',
|
||||||
|
'Mar',
|
||||||
|
'Abr',
|
||||||
|
'Mai',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Ago',
|
||||||
|
'Set',
|
||||||
|
'Out',
|
||||||
|
'Nov',
|
||||||
|
'Dez',
|
||||||
|
];
|
||||||
|
const periodo =
|
||||||
|
mes != null && ano != null
|
||||||
|
? `${meses[mes - 1]}/${ano}`
|
||||||
|
: ano != null
|
||||||
|
? String(ano)
|
||||||
|
: 'Acumulado';
|
||||||
|
|
||||||
|
return { data, totalFaturado: totalGeral.toFixed(2), periodo };
|
||||||
|
}
|
||||||
|
}
|
||||||
91
apps/api/src/app/sac/sac.controller.ts
Normal file
91
apps/api/src/app/sac/sac.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { SacService } from './sac.service';
|
||||||
|
|
||||||
|
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
|
||||||
|
class AddMensagemDto extends createZodDto(AddMensagemSchema) {}
|
||||||
|
class ResolverDto extends createZodDto(ResolverChamadoSchema) {}
|
||||||
|
|
||||||
|
@Controller('chamados')
|
||||||
|
export class SacRepController {
|
||||||
|
constructor(private readonly sac: SacService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listar() {
|
||||||
|
return this.sac.listarRep();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detalhe(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.sac.detalhe(id, 'rep');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
criar(@Body() dto: CreateChamadoDto) {
|
||||||
|
return this.sac.criar(CreateChamadoSchema.parse(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/mensagens')
|
||||||
|
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
|
||||||
|
return this.sac.addMensagemRep(id, AddMensagemSchema.parse(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/cancelar')
|
||||||
|
cancelar(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.sac.cancelarRep(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('ger/chamados')
|
||||||
|
export class SacGerController {
|
||||||
|
constructor(
|
||||||
|
private readonly sac: SacService,
|
||||||
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: visão da empresa toda (detalhe sem checagem de dono) — só gestores.
|
||||||
|
private assertGestor(): void {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role === 'rep') {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Apenas supervisores e gerentes podem acessar os chamados da empresa',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listar() {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.sac.listarEmpresa();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detalhe(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.sac.detalhe(id, 'empresa');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/mensagens')
|
||||||
|
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/resolver')
|
||||||
|
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/sac/sac.module.ts
Normal file
9
apps/api/src/app/sac/sac.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SacService } from './sac.service';
|
||||||
|
import { SacRepController, SacGerController } from './sac.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SacRepController, SacGerController],
|
||||||
|
providers: [SacService],
|
||||||
|
})
|
||||||
|
export class SacModule {}
|
||||||
195
apps/api/src/app/sac/sac.service.ts
Normal file
195
apps/api/src/app/sac/sac.service.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type { CreateChamadoDto, AddMensagemDto, ResolverChamadoDto } from '@sar/api-interface';
|
||||||
|
|
||||||
|
type PrismaCtx = {
|
||||||
|
chamado: {
|
||||||
|
findMany: (args: object) => Promise<unknown[]>;
|
||||||
|
findFirst: (args: object) => Promise<unknown | null>;
|
||||||
|
findUnique: (args: object) => Promise<unknown | null>;
|
||||||
|
create: (args: object) => Promise<unknown>;
|
||||||
|
update: (args: object) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
chamadoMensagem: { create: (args: object) => Promise<unknown> };
|
||||||
|
$queryRawUnsafe: <T>(sql: string, ...args: unknown[]) => Promise<T[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClienteRow = { id_cliente: number; nome: string | null; razao: string | null };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SacService {
|
||||||
|
constructor(private readonly cls: ClsService) {}
|
||||||
|
|
||||||
|
private ctx(): { prisma: PrismaCtx; idEmpresa: number; codVendedor: number } {
|
||||||
|
const prisma = this.cls.get('prisma') as PrismaCtx;
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa') as number;
|
||||||
|
const userId = this.cls.get('userId') as string | undefined;
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
return { prisma, idEmpresa, codVendedor };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichNomes(
|
||||||
|
prisma: PrismaCtx,
|
||||||
|
idEmpresa: number,
|
||||||
|
rows: { idCliente: number }[],
|
||||||
|
): Promise<Map<number, string>> {
|
||||||
|
const ids = [...new Set(rows.map((r) => r.idCliente))];
|
||||||
|
if (!ids.length) return new Map();
|
||||||
|
const clientes = await prisma.$queryRawUnsafe<ClienteRow>(
|
||||||
|
`SELECT id_cliente, TRIM(nome) AS nome, TRIM(razao) AS razao FROM vw_clientes WHERE id_cliente IN (${ids.join(',')}) AND id_empresa = ${idEmpresa}`,
|
||||||
|
);
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
for (const c of clientes) {
|
||||||
|
map.set(c.id_cliente, c.razao ?? c.nome ?? `Cód. ${c.id_cliente}`);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarRep() {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const all = (await prisma.chamado.findMany({
|
||||||
|
where: { idEmpresa, codVendedor },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})) as { idCliente: number; status: string; [k: string]: unknown }[];
|
||||||
|
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
|
||||||
|
const enrich = (row: (typeof all)[0]) => ({
|
||||||
|
...row,
|
||||||
|
nomeCliente:
|
||||||
|
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||||
|
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Códigos da equipe do supervisor (cod_supervisor em vw_representantes),
|
||||||
|
// incluindo o próprio. Retorna null para gerente/admin (sem filtro).
|
||||||
|
private async teamCodes(): Promise<number[] | null> {
|
||||||
|
const role = (this.cls.get('role') as string | undefined) ?? 'rep';
|
||||||
|
if (role !== 'supervisor') return null;
|
||||||
|
const { prisma, codVendedor } = this.ctx();
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ codigo: number }>(
|
||||||
|
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codVendedor}`,
|
||||||
|
);
|
||||||
|
return [...new Set([codVendedor, ...rows.map((r) => Number(r.codigo))])];
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarEmpresa() {
|
||||||
|
const { prisma, idEmpresa } = this.ctx();
|
||||||
|
// Supervisor vê só os chamados da sua equipe; gerente/admin veem a empresa toda
|
||||||
|
const team = await this.teamCodes();
|
||||||
|
const all = (await prisma.chamado.findMany({
|
||||||
|
where: { idEmpresa, ...(team ? { codVendedor: { in: team } } : {}) },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})) as { idCliente: number; status: string; [k: string]: unknown }[];
|
||||||
|
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
|
||||||
|
const enrich = (row: (typeof all)[0]) => ({
|
||||||
|
...row,
|
||||||
|
nomeCliente:
|
||||||
|
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||||
|
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detalhe(id: number, role: 'rep' | 'empresa') {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const chamado = (await prisma.chamado.findFirst({
|
||||||
|
where: { id, idEmpresa },
|
||||||
|
include: { mensagens: { orderBy: { createdAt: 'asc' } } },
|
||||||
|
})) as { idCliente: number; codVendedor: number; [k: string]: unknown } | null;
|
||||||
|
if (!chamado) throw new NotFoundException('Chamado não encontrado');
|
||||||
|
if (role === 'rep' && chamado.codVendedor !== codVendedor) throw new ForbiddenException();
|
||||||
|
if (role === 'empresa') {
|
||||||
|
const team = await this.teamCodes();
|
||||||
|
if (team && !team.includes(chamado.codVendedor)) throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
|
||||||
|
return { ...chamado, nomeCliente: nomes.get(chamado.idCliente) ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async criar(dto: CreateChamadoDto) {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const chamado = (await prisma.chamado.create({
|
||||||
|
data: {
|
||||||
|
idEmpresa,
|
||||||
|
codVendedor,
|
||||||
|
idPedido: dto.idPedido ?? null,
|
||||||
|
numPedSar: dto.numPedSar ?? null,
|
||||||
|
numPedErp: dto.numPedErp ?? null,
|
||||||
|
nomeCliente: dto.nomeCliente ?? null,
|
||||||
|
idCliente: dto.idCliente,
|
||||||
|
tipo: dto.tipo,
|
||||||
|
assunto: dto.assunto,
|
||||||
|
descricao: dto.descricao,
|
||||||
|
itensAfetados: dto.itensAfetados ?? [],
|
||||||
|
},
|
||||||
|
include: { mensagens: true },
|
||||||
|
})) as { idCliente: number; nomeCliente: string | null; [k: string]: unknown };
|
||||||
|
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
|
||||||
|
return { ...chamado, nomeCliente: chamado.nomeCliente ?? nomes.get(chamado.idCliente) ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMensagemRep(id: number, dto: AddMensagemDto) {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
|
||||||
|
codVendedor: number;
|
||||||
|
} | null;
|
||||||
|
if (!c) throw new NotFoundException();
|
||||||
|
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
|
||||||
|
await prisma.chamadoMensagem.create({
|
||||||
|
data: { idChamado: id, autor: 'rep', texto: dto.texto },
|
||||||
|
});
|
||||||
|
await prisma.chamado.update({ where: { id }, data: { status: 'em_analise' } });
|
||||||
|
return this.detalhe(id, 'rep');
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMensagemEmpresa(id: number, dto: AddMensagemDto) {
|
||||||
|
const { prisma, idEmpresa } = this.ctx();
|
||||||
|
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!c) throw new NotFoundException();
|
||||||
|
await prisma.chamadoMensagem.create({
|
||||||
|
data: { idChamado: id, autor: 'empresa', texto: dto.texto },
|
||||||
|
});
|
||||||
|
await prisma.chamado.update({ where: { id }, data: { status: 'aguardando_rep' } });
|
||||||
|
return this.detalhe(id, 'empresa');
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolver(id: number, dto: ResolverChamadoDto) {
|
||||||
|
const { prisma, idEmpresa } = this.ctx();
|
||||||
|
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!c) throw new NotFoundException();
|
||||||
|
if (dto.mensagemFinal) {
|
||||||
|
await prisma.chamadoMensagem.create({
|
||||||
|
data: { idChamado: id, autor: 'empresa', texto: dto.mensagemFinal },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.chamado.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'resolvido',
|
||||||
|
resolucao: dto.resolucao,
|
||||||
|
notaResolucao: dto.notaResolucao ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.detalhe(id, 'empresa');
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelarRep(id: number) {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
|
||||||
|
codVendedor: number;
|
||||||
|
status: string;
|
||||||
|
} | null;
|
||||||
|
if (!c) throw new NotFoundException();
|
||||||
|
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
|
||||||
|
if (['resolvido', 'cancelado'].includes(c.status))
|
||||||
|
throw new ForbiddenException('Chamado já encerrado');
|
||||||
|
await prisma.chamado.update({ where: { id }, data: { status: 'cancelado' } });
|
||||||
|
return this.detalhe(id, 'rep');
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/app/workspace/team-scope.util.ts
Normal file
14
apps/api/src/app/workspace/team-scope.util.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
// Códigos de vendedor da equipe de um supervisor (vw_representantes.cod_supervisor
|
||||||
|
// aponta para o código do supervisor), incluindo o próprio supervisor. Usado para
|
||||||
|
// escopar as telas do supervisor à sua equipe — gerente/admin veem a empresa toda.
|
||||||
|
// codSupervisor vem do JWT (parseInt) — interpolação segura.
|
||||||
|
export async function getTeamCodes(prisma: PrismaClient, codSupervisor: number): Promise<number[]> {
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ codigo: number }[]>(
|
||||||
|
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codSupervisor}`,
|
||||||
|
);
|
||||||
|
const codes = new Set(rows.map((r) => Number(r.codigo)));
|
||||||
|
codes.add(codSupervisor);
|
||||||
|
return [...codes];
|
||||||
|
}
|
||||||
86
apps/api/src/app/workspace/workspace-prisma-pool.service.ts
Normal file
86
apps/api/src/app/workspace/workspace-prisma-pool.service.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
// ADR 0006 revogado: BD-por-workspace → schema `sar` no ERP.
|
||||||
|
// Pool keyed por idEmpresa (number). URL deve incluir ?schema=sar.
|
||||||
|
// CODING-RULES PGD-DB-009: callers obtêm o client via CLS, não injetando este serviço.
|
||||||
|
|
||||||
|
const MAX_ENTRIES = 10; // LRU cap; ajustável via env na próxima iteração
|
||||||
|
const PG_POOL_SIZE = 5;
|
||||||
|
const noop = (): void => undefined;
|
||||||
|
|
||||||
|
interface PoolEntry {
|
||||||
|
client: PrismaClient;
|
||||||
|
pgPool: pg.Pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspacePrismaPool implements OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(WorkspacePrismaPool.name);
|
||||||
|
|
||||||
|
// Map preserves insertion order → LRU: primeiro = mais antigo, último = mais recente
|
||||||
|
private readonly cache = new Map<string, PoolEntry>();
|
||||||
|
|
||||||
|
getOrCreate(idEmpresa: number, dbUrl: string): PrismaClient {
|
||||||
|
const key = String(idEmpresa);
|
||||||
|
const hit = this.cache.get(key);
|
||||||
|
if (hit) {
|
||||||
|
// Move para o fim (LRU refresh)
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.cache.set(key, hit);
|
||||||
|
return hit.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cache.size >= MAX_ENTRIES) {
|
||||||
|
this.evictOldest();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pgPool = new pg.Pool({
|
||||||
|
connectionString: dbUrl,
|
||||||
|
max: PG_POOL_SIZE,
|
||||||
|
options: '-c search_path=sar',
|
||||||
|
});
|
||||||
|
const adapter = new PrismaPg(pgPool, { schema: 'sar' });
|
||||||
|
const client = new PrismaClient({ adapter });
|
||||||
|
this.cache.set(key, { client, pgPool });
|
||||||
|
this.logger.log(`pool criado: idEmpresa=${idEmpresa} total=${this.cache.size}`);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(k = 3): Promise<{ workspaceId: string; ok: boolean; latencyMs?: number }[]> {
|
||||||
|
// Verifica os k empresas mais recentes
|
||||||
|
const entries = [...this.cache.entries()].slice(-k);
|
||||||
|
return Promise.all(
|
||||||
|
entries.map(async ([workspaceId, { pgPool }]) => {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const conn = await pgPool.connect();
|
||||||
|
conn.release();
|
||||||
|
return { workspaceId, ok: true, latencyMs: Date.now() - start };
|
||||||
|
} catch {
|
||||||
|
return { workspaceId, ok: false };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await Promise.allSettled(
|
||||||
|
[...this.cache.values()].map(({ client, pgPool }) =>
|
||||||
|
client.$disconnect().finally(() => pgPool.end()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.cache.clear();
|
||||||
|
this.logger.log('pool destruído — todos os clientes desconectados');
|
||||||
|
}
|
||||||
|
|
||||||
|
private evictOldest(): void {
|
||||||
|
const [oldestId, oldest] = this.cache.entries().next().value as [string, PoolEntry];
|
||||||
|
void oldest.client.$disconnect().catch(noop);
|
||||||
|
void oldest.pgPool.end().catch(noop);
|
||||||
|
this.cache.delete(oldestId);
|
||||||
|
this.logger.log(`evicted LRU idEmpresa=${oldestId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,48 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { WorkspaceClsStore } from './workspace.types';
|
import type { WorkspaceClsStore } from './workspace.types';
|
||||||
import type { Env } from '../config/env.schema';
|
import { WorkspacePrismaPool } from './workspace-prisma-pool.service';
|
||||||
|
|
||||||
// CLS popula contexto por request. Hoje: requestId + DEFAULT_WORKSPACE_ID do env.
|
// CLS middleware roda ANTES dos guards (ordem NestJS).
|
||||||
// Amanhã: workspaceId vem do JWT (PGD-AUTHZ-002); `prisma` é resolvido pelo
|
// Aqui: apenas requestId + idEmpresa default (0 = não autenticado).
|
||||||
// WorkspacePrismaPool e injetado via cls.set('prisma', ...) aqui mesmo.
|
// JwtAuthGuard atualiza idEmpresa, userId e prisma após validar o token.
|
||||||
|
// CODING-RULES PGD-DB-009: prisma via cls.get('prisma'), nunca singleton.
|
||||||
|
// CODING-RULES PGD-AUTHZ-002: idEmpresa real vem do JWT (guard), não do env.
|
||||||
|
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ClsModule.forRootAsync({
|
ClsModule.forRootAsync({
|
||||||
global: true,
|
global: true,
|
||||||
imports: [ConfigModule],
|
useFactory: () => ({
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService<Env, true>) => ({
|
|
||||||
middleware: {
|
middleware: {
|
||||||
mount: true,
|
mount: true,
|
||||||
generateId: true,
|
generateId: true,
|
||||||
idGenerator: (req: Request) => {
|
idGenerator: (req: Request) => {
|
||||||
// Prioridade: req.id (pino-http já gerou/leu header) > header bruto > novo UUID.
|
|
||||||
const fromPino = (req as Request & { id?: unknown }).id;
|
const fromPino = (req as Request & { id?: unknown }).id;
|
||||||
if (typeof fromPino === 'string' && fromPino.length > 0) return fromPino;
|
if (typeof fromPino === 'string' && fromPino.length > 0) return fromPino;
|
||||||
const headerVal = req.headers['x-request-id'];
|
const headerVal = req.headers['x-request-id'];
|
||||||
return typeof headerVal === 'string' && headerVal.length > 0
|
return typeof headerVal === 'string' && headerVal.length > 0 ? headerVal : randomUUID();
|
||||||
? headerVal
|
|
||||||
: randomUUID();
|
|
||||||
},
|
},
|
||||||
setup: (cls, req: Request, res: Response) => {
|
setup: (cls, req: Request, res: Response) => {
|
||||||
const store = cls as unknown as {
|
const store = cls as unknown as {
|
||||||
set: <K extends keyof WorkspaceClsStore>(key: K, value: WorkspaceClsStore[K]) => void;
|
set: <K extends keyof WorkspaceClsStore>(key: K, value: WorkspaceClsStore[K]) => void;
|
||||||
getId: () => string;
|
getId: () => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestId = store.getId();
|
const requestId = store.getId();
|
||||||
res.setHeader('x-request-id', requestId);
|
res.setHeader('x-request-id', requestId);
|
||||||
store.set('requestId', requestId);
|
store.set('requestId', requestId);
|
||||||
store.set('workspaceId', config.get('DEFAULT_WORKSPACE_ID', { infer: true }));
|
// Fallback para rotas públicas (ping, health). Guard sobrescreve em rotas protegidas.
|
||||||
|
store.set('idEmpresa', 0);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
exports: [ClsModule],
|
providers: [WorkspacePrismaPool],
|
||||||
|
exports: [ClsModule, WorkspacePrismaPool],
|
||||||
})
|
})
|
||||||
export class WorkspaceModule {}
|
export class WorkspaceModule {}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import type { ClsStore } from 'nestjs-cls';
|
import type { ClsStore } from 'nestjs-cls';
|
||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import type { JwtRole } from '../auth/jwt.types';
|
||||||
|
|
||||||
// Forma do CLS store por request — fonte da verdade para qualquer caller
|
// Forma do CLS store por request — fonte da verdade para qualquer caller.
|
||||||
// que faça `cls.get(...)`. Quando o PrismaClient por workspace entrar
|
// CODING-RULES PGD-DB-009: nunca importe PrismaClient diretamente; use cls.get('prisma').
|
||||||
// (ADR 0006), `prisma` virará obrigatório aqui — por hora segue opcional.
|
// CODING-RULES PGD-AUTHZ-002: idEmpresa vem sempre do JWT, nunca de body/param/query.
|
||||||
|
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
|
||||||
|
|
||||||
export interface WorkspaceClsStore extends ClsStore {
|
export interface WorkspaceClsStore extends ClsStore {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
workspaceId: string;
|
idEmpresa: number; // era workspaceId: string — agora Int da empresa no ERP
|
||||||
// userId virá quando master-login estiver plugado.
|
userId?: string; // cod_vendedor como string; preenchido pelo JwtAuthGuard
|
||||||
userId?: string;
|
role?: JwtRole; // preenchido pelo JwtAuthGuard após validar o token
|
||||||
// prisma: PrismaClient — adicionar quando WorkspacePrismaPool entrar.
|
prisma?: PrismaClient; // preenchido pelo JwtAuthGuard via WorkspacePrismaPool
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/api/tsconfig.tsbuildinfo
Normal file
1
apps/api/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"fileNames":[],"fileInfos":[],"root":[],"options":{"allowSyntheticDefaultImports":true,"composite":false,"declaration":true,"declarationMap":true,"emitDecoratorMetadata":true,"esModuleInterop":true,"experimentalDecorators":true,"module":199,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noUncheckedIndexedAccess":true,"skipLibCheck":true,"sourceMap":true,"strict":true,"target":10,"useDefineForClassFields":false,"verbatimModuleSyntax":false},"version":"5.9.3"}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"sourceRoot": "apps/web-e2e/src",
|
"sourceRoot": "apps/web-e2e/src",
|
||||||
"tags": [],
|
"tags": ["scope:web", "type:e2e", "domain:shared"],
|
||||||
"implicitDependencies": ["web"],
|
"implicitDependencies": ["web"],
|
||||||
"// targets": "to see all targets run: nx show project web-e2e --web",
|
"// targets": "to see all targets run: nx show project web-e2e --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
|
|||||||
123
apps/web/public/sw.js
Normal file
123
apps/web/public/sw.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Service Worker SAR
|
||||||
|
// C4/NFR-2: cache de API para uso offline (network-first, fallback to cache)
|
||||||
|
// C6: Web Push
|
||||||
|
// App shell: stale-while-revalidate para assets estáticos
|
||||||
|
|
||||||
|
const API_CACHE = 'sar-api-v2';
|
||||||
|
const SHELL_CACHE = 'sar-shell-v2';
|
||||||
|
|
||||||
|
// Paths de API que valem ser cacheados para offline
|
||||||
|
// Auth e mutations (POST/PATCH) nunca são interceptados
|
||||||
|
const CACHEABLE_API = [
|
||||||
|
'/api/v1/clients',
|
||||||
|
'/api/v1/catalog',
|
||||||
|
'/api/v1/orders',
|
||||||
|
'/api/v1/dashboard',
|
||||||
|
'/api/v1/auth/me',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Fetch ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
globalThis.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/api/v1/')) {
|
||||||
|
const cacheable = CACHEABLE_API.some((p) => url.pathname.startsWith(p));
|
||||||
|
if (cacheable) {
|
||||||
|
event.respondWith(networkFirst(request, API_CACHE));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
// App shell HTML — network first, cache fallback
|
||||||
|
event.respondWith(shellNavigate(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets estáticos (JS/CSS/fontes/imagens) — stale-while-revalidate
|
||||||
|
if (/\.(js|css|woff2?|png|svg|ico)$/.test(url.pathname)) {
|
||||||
|
event.respondWith(staleWhileRevalidate(request, SHELL_CACHE));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Network-first: tenta rede, cai no cache se offline
|
||||||
|
async function networkFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
return offlineResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate: network first; fallback para a raiz cacheada
|
||||||
|
async function shellNavigate(request) {
|
||||||
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
// Sempre armazena a raiz como fallback universal
|
||||||
|
cache.put(new Request('/'), response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
const cached = (await cache.match(request)) ?? (await cache.match('/'));
|
||||||
|
if (cached) return cached;
|
||||||
|
return offlineResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale-while-revalidate: responde do cache, atualiza em background
|
||||||
|
async function staleWhileRevalidate(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
const networkFetch = fetch(request).then((response) => {
|
||||||
|
if (response.ok) cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
return cached ?? networkFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function offlineResponse() {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'sar:offline',
|
||||||
|
title: 'Sem conexão',
|
||||||
|
status: 503,
|
||||||
|
}),
|
||||||
|
{ status: 503, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Push (C6) ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
globalThis.addEventListener('push', (event) => {
|
||||||
|
const data = event.data?.json() ?? {};
|
||||||
|
event.waitUntil(
|
||||||
|
globalThis.registration.showNotification(data.title ?? 'SAR', {
|
||||||
|
body: data.body ?? '',
|
||||||
|
icon: '/sar-icon.png',
|
||||||
|
badge: '/sar-icon.png',
|
||||||
|
data: data.url ? { url: data.url } : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = event.notification.data?.url;
|
||||||
|
if (url) {
|
||||||
|
event.waitUntil(clients.openWindow(url));
|
||||||
|
}
|
||||||
|
});
|
||||||
122
apps/web/src/cockpits/ger/EquipePage.tsx
Normal file
122
apps/web/src/cockpits/ger/EquipePage.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import type { RepStats } from '@sar/api-interface';
|
||||||
|
import { useEquipe } from '../../lib/queries/gerente';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
function fmt(v: number): string {
|
||||||
|
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<RepStats> = [
|
||||||
|
{
|
||||||
|
title: 'Representante',
|
||||||
|
key: 'rep',
|
||||||
|
render: (_: unknown, row: RepStats) => (
|
||||||
|
<Flex vertical gap={0}>
|
||||||
|
<Text strong>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
||||||
|
{row.nomeVendedor && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
cód. {row.codVendedor}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'pedidosMes',
|
||||||
|
width: 90,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
sorter: (a, b) => a.pedidosMes - b.pedidosMes,
|
||||||
|
render: (v: number) => (v === 0 ? <Tag>0</Tag> : v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Faturamento',
|
||||||
|
dataIndex: 'faturamentoMes',
|
||||||
|
width: 160,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
sorter: (a, b) => a.faturamentoMes - b.faturamentoMes,
|
||||||
|
defaultSortOrder: 'descend',
|
||||||
|
render: (v: number) => fmt(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ticket Medio',
|
||||||
|
dataIndex: 'ticketMedio',
|
||||||
|
width: 140,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
sorter: (a, b) => a.ticketMedio - b.ticketMedio,
|
||||||
|
render: (v: number) => (v > 0 ? fmt(v) : <Text type="secondary">--</Text>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Meta',
|
||||||
|
dataIndex: 'pctMeta',
|
||||||
|
width: 170,
|
||||||
|
sorter: (a, b) => a.pctMeta - b.pctMeta,
|
||||||
|
render: (pct: number) =>
|
||||||
|
pct === 0 ? (
|
||||||
|
<Text type="secondary">sem meta</Text>
|
||||||
|
) : (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(pct, 100)}
|
||||||
|
size="small"
|
||||||
|
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
||||||
|
showInfo={false}
|
||||||
|
style={{ flex: 1, minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EquipePage() {
|
||||||
|
const { data, isLoading } = useEquipe();
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 1 }} />
|
||||||
|
<Skeleton active paragraph={{ rows: 6 }} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
|
Equipe
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
|
Performance dos representantes — {mes}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table<RepStats>
|
||||||
|
rowKey="codVendedor"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.reps}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum representante encontrado.' }}
|
||||||
|
scroll={{ x: 700 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
apps/web/src/cockpits/ger/GerChamadosPage.tsx
Normal file
346
apps/web/src/cockpits/ger/GerChamadosPage.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Tabs,
|
||||||
|
} from 'antd';
|
||||||
|
import { CustomerServiceOutlined } from '@ant-design/icons';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import {
|
||||||
|
STATUS_CHAMADO_LABEL,
|
||||||
|
TIPO_CHAMADO_LABEL,
|
||||||
|
RESOLUCAO_CHAMADO,
|
||||||
|
RESOLUCAO_CHAMADO_LABEL,
|
||||||
|
type Chamado,
|
||||||
|
type ChamadoMensagem,
|
||||||
|
type ItemAfetado,
|
||||||
|
type ResolverChamadoDto,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import {
|
||||||
|
useChamadosGer,
|
||||||
|
useChamadoGer,
|
||||||
|
useAddMensagemEmpresa,
|
||||||
|
useResolverChamado,
|
||||||
|
} from '../../lib/queries/sac';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
aberto: 'blue',
|
||||||
|
em_analise: 'orange',
|
||||||
|
aguardando_rep: 'purple',
|
||||||
|
resolvido: 'green',
|
||||||
|
cancelado: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ResolverModal({ id, open, onClose }: { id: number; open: boolean; onClose: () => void }) {
|
||||||
|
const [form] = Form.useForm<ResolverChamadoDto>();
|
||||||
|
const resolver = useResolverChamado(id);
|
||||||
|
|
||||||
|
function handleOk() {
|
||||||
|
form.validateFields().then((values) => {
|
||||||
|
resolver.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
form.resetFields();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Resolver Chamado"
|
||||||
|
open={open}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={() => {
|
||||||
|
form.resetFields();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
okText="Confirmar Resolução"
|
||||||
|
cancelText="Voltar"
|
||||||
|
confirmLoading={resolver.isPending}
|
||||||
|
width={560}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="resolucao" label="Tipo de Resolução" rules={[{ required: true }]}>
|
||||||
|
<Select placeholder="Selecione...">
|
||||||
|
{RESOLUCAO_CHAMADO.map((r) => (
|
||||||
|
<Select.Option key={r} value={r}>
|
||||||
|
{RESOLUCAO_CHAMADO_LABEL[r]}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notaResolucao" label="Nota / Detalhes">
|
||||||
|
<TextArea rows={3} maxLength={1000} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="mensagemFinal" label="Mensagem final para o representante (opcional)">
|
||||||
|
<TextArea rows={2} maxLength={500} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChamadoDetalheModal({
|
||||||
|
id,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [texto, setTexto] = useState('');
|
||||||
|
const [resolverOpen, setResolverOpen] = useState(false);
|
||||||
|
const { data: chamado, isLoading } = useChamadoGer(id);
|
||||||
|
const addMsg = useAddMensagemEmpresa(id);
|
||||||
|
|
||||||
|
function enviar() {
|
||||||
|
if (!texto.trim()) return;
|
||||||
|
addMsg.mutate({ texto: texto.trim() }, { onSuccess: () => setTexto('') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fechado = chamado && ['resolvido', 'cancelado'].includes(chamado.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
title={chamado ? `Chamado #${chamado.id} — ${chamado.assunto}` : 'Chamado'}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={720}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{isLoading || !chamado ? (
|
||||||
|
<Spin />
|
||||||
|
) : (
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color={STATUS_COLOR[chamado.status]}>{STATUS_CHAMADO_LABEL[chamado.status]}</Tag>
|
||||||
|
<Tag>{TIPO_CHAMADO_LABEL[chamado.tipo] ?? chamado.tipo}</Tag>
|
||||||
|
<Text type="secondary">Rep: {chamado.codVendedor}</Text>
|
||||||
|
<Text type="secondary">Cliente: {chamado.nomeCliente ?? chamado.idCliente}</Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Text type="secondary">{chamado.descricao}</Text>
|
||||||
|
|
||||||
|
{chamado.notaResolucao && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f6ffed',
|
||||||
|
border: '1px solid #b7eb8f',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '8px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong>Resolução: </Text>
|
||||||
|
<Text>{chamado.notaResolucao}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chamado.itensAfetados && chamado.itensAfetados.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Text strong>Itens afetados: </Text>
|
||||||
|
{(chamado.itensAfetados as ItemAfetado[]).map((it, i) => (
|
||||||
|
<Tag key={`${it.codProduto}-${i}`}>
|
||||||
|
{it.codProduto} — {it.nomeProduto} (Qtd: {it.qtd})
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
maxHeight: 320,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(chamado.mensagens ?? []).length === 0 ? (
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ textAlign: 'center', display: 'block', padding: '16px 0' }}
|
||||||
|
>
|
||||||
|
Sem mensagens ainda.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
((chamado.mensagens as ChamadoMensagem[]) ?? []).map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: m.autor === 'empresa' ? 'flex-end' : 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '72%',
|
||||||
|
background: m.autor === 'empresa' ? '#1677ff' : '#f5f5f5',
|
||||||
|
color: m.autor === 'empresa' ? '#fff' : '#000',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 2 }}>
|
||||||
|
{m.autor === 'empresa' ? 'Empresa' : 'Representante'}
|
||||||
|
</div>
|
||||||
|
<div>{m.texto}</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4, textAlign: 'right' }}>
|
||||||
|
{new Date(m.createdAt).toLocaleString('pt-BR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!fechado && (
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="Resposta">
|
||||||
|
<TextArea
|
||||||
|
rows={2}
|
||||||
|
value={texto}
|
||||||
|
onChange={(e) => setTexto(e.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={enviar}
|
||||||
|
loading={addMsg.isPending}
|
||||||
|
disabled={!texto.trim()}
|
||||||
|
>
|
||||||
|
Responder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
style={{ backgroundColor: '#52c41a', borderColor: '#52c41a' }}
|
||||||
|
onClick={() => setResolverOpen(true)}
|
||||||
|
>
|
||||||
|
Resolver
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ResolverModal id={id} open={resolverOpen} onClose={() => setResolverOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<Chamado> = [
|
||||||
|
{ title: '#', dataIndex: 'id', width: 60 },
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 140,
|
||||||
|
render: (s: string) => (
|
||||||
|
<Tag color={STATUS_COLOR[s] ?? 'default'}>{STATUS_CHAMADO_LABEL[s] ?? s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tipo',
|
||||||
|
dataIndex: 'tipo',
|
||||||
|
width: 160,
|
||||||
|
render: (t: string) => TIPO_CHAMADO_LABEL[t] ?? t,
|
||||||
|
},
|
||||||
|
{ title: 'Assunto', dataIndex: 'assunto', ellipsis: true },
|
||||||
|
{ title: 'Rep', dataIndex: 'codVendedor', width: 70 },
|
||||||
|
{ title: 'Cliente', dataIndex: 'nomeCliente', width: 160 },
|
||||||
|
{
|
||||||
|
title: 'Atualizado',
|
||||||
|
dataIndex: 'updatedAt',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GerChamadosPage() {
|
||||||
|
const { data, isLoading } = useChamadosGer();
|
||||||
|
const [detalheId, setDetalheId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||||
|
|
||||||
|
const abertos = data?.abertos ?? [];
|
||||||
|
const fechados = data?.fechados ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
|
||||||
|
<Space align="center" style={{ marginBottom: 24 }}>
|
||||||
|
<CustomerServiceOutlined style={{ fontSize: 24, color: '#1677ff' }} />
|
||||||
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
|
SAC — Chamados da Empresa
|
||||||
|
</Title>
|
||||||
|
{abertos.length > 0 && <Badge count={abertos.length} />}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'abertos',
|
||||||
|
label: `Abertos (${abertos.length})`,
|
||||||
|
children: (
|
||||||
|
<Table<Chamado>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={abertos}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||||
|
locale={{ emptyText: 'Nenhum chamado aberto.' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fechados',
|
||||||
|
label: `Fechados (${fechados.length})`,
|
||||||
|
children: (
|
||||||
|
<Table<Chamado>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={fechados}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
size="small"
|
||||||
|
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||||
|
locale={{ emptyText: 'Nenhum chamado fechado.' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{detalheId !== null && (
|
||||||
|
<ChamadoDetalheModal
|
||||||
|
id={detalheId}
|
||||||
|
open={detalheId !== null}
|
||||||
|
onClose={() => setDetalheId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
672
apps/web/src/cockpits/ger/GerPainel.tsx
Normal file
672
apps/web/src/cockpits/ger/GerPainel.tsx
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Flex,
|
||||||
|
InputNumber,
|
||||||
|
Progress,
|
||||||
|
Row,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faChartBar,
|
||||||
|
faTags,
|
||||||
|
faUsers,
|
||||||
|
faFileInvoiceDollar,
|
||||||
|
faAddressCard,
|
||||||
|
faBullseye,
|
||||||
|
faArrowTrendUp,
|
||||||
|
faCalendarDay,
|
||||||
|
faLayerGroup,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Tooltip as ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Chart } from 'react-chartjs-2';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
|
||||||
|
import { useManagerDashboard } from '../../lib/queries/gerente';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
// Meta diária de positivação é preferência local do gerente (não vem do ERP)
|
||||||
|
const META_POSITIVACAO_DIA_KEY = 'sar:ger:meta-positivacao-dia';
|
||||||
|
|
||||||
|
function fmt(v: number): string {
|
||||||
|
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function today(): string {
|
||||||
|
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const positivacaoColumns: TableColumnsType<PositivacaoRep> = [
|
||||||
|
{
|
||||||
|
title: 'Representante',
|
||||||
|
key: 'rep',
|
||||||
|
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Positivados / Carteira',
|
||||||
|
key: 'pos',
|
||||||
|
width: 160,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: unknown, row: PositivacaoRep) => (
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
{row.clientesPositivados} / {row.totalClientes}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Positivação',
|
||||||
|
dataIndex: 'pctPositivacao',
|
||||||
|
width: 170,
|
||||||
|
render: (pct: number) => (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(pct, 100)}
|
||||||
|
size="small"
|
||||||
|
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
|
||||||
|
showInfo={false}
|
||||||
|
style={{ flex: 1, minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const metasGrupoColumns: TableColumnsType<MetaItem> = [
|
||||||
|
{
|
||||||
|
title: 'Grupo',
|
||||||
|
dataIndex: 'rotulo',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Meta',
|
||||||
|
dataIndex: 'valorMeta',
|
||||||
|
width: 150,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => fmt(v),
|
||||||
|
className: 'tabular-nums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Realizado',
|
||||||
|
dataIndex: 'valorReal',
|
||||||
|
width: 150,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => fmt(v),
|
||||||
|
className: 'tabular-nums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Falta',
|
||||||
|
dataIndex: 'falta',
|
||||||
|
width: 130,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => (
|
||||||
|
<Text
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{ fontSize: 'var(--text-sm)', color: v === 0 ? 'var(--green)' : undefined }}
|
||||||
|
>
|
||||||
|
{v === 0 ? '—' : fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Meta',
|
||||||
|
dataIndex: 'pct',
|
||||||
|
width: 170,
|
||||||
|
render: (pct: number) => (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(pct, 100)}
|
||||||
|
size="small"
|
||||||
|
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
||||||
|
showInfo={false}
|
||||||
|
style={{ flex: 1, minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 48 }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rankingColumns: TableColumnsType<RankingRep> = [
|
||||||
|
{
|
||||||
|
title: 'Representante',
|
||||||
|
key: 'rep',
|
||||||
|
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'pedidos',
|
||||||
|
width: 80,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Clientes',
|
||||||
|
dataIndex: 'clientesAtendidos',
|
||||||
|
width: 80,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Faturamento',
|
||||||
|
dataIndex: 'faturamento',
|
||||||
|
width: 150,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => fmt(v),
|
||||||
|
className: 'tabular-nums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Meta',
|
||||||
|
dataIndex: 'pctMeta',
|
||||||
|
width: 160,
|
||||||
|
render: (pct: number) => (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(pct, 100)}
|
||||||
|
size="small"
|
||||||
|
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
||||||
|
showInfo={false}
|
||||||
|
style={{ flex: 1, minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function PositivacaoDiariaCard({
|
||||||
|
dados,
|
||||||
|
periodo,
|
||||||
|
periodoLabel,
|
||||||
|
isMesAtual,
|
||||||
|
}: {
|
||||||
|
dados: PositivacaoDia[];
|
||||||
|
periodo: Dayjs;
|
||||||
|
periodoLabel: string;
|
||||||
|
isMesAtual: boolean;
|
||||||
|
}) {
|
||||||
|
const [meta, setMeta] = useState<number>(() => {
|
||||||
|
const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY));
|
||||||
|
return Number.isFinite(saved) && saved > 0 ? saved : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function changeMeta(v: number | null) {
|
||||||
|
const val = v ?? 0;
|
||||||
|
setMeta(val);
|
||||||
|
localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eixo com todos os dias: até hoje no mês atual, mês inteiro nos anteriores
|
||||||
|
const ultimoDia = isMesAtual ? dayjs().date() : periodo.endOf('month').date();
|
||||||
|
const porDia = new Map(dados.map((d) => [dayjs(d.dia).date(), d.clientes]));
|
||||||
|
const labels = Array.from({ length: ultimoDia }, (_, i) => String(i + 1));
|
||||||
|
const valores = labels.map((d) => porDia.get(Number(d)) ?? 0);
|
||||||
|
const diasComMeta = meta > 0 ? valores.filter((v) => v >= meta).length : 0;
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
type: 'bar' as const,
|
||||||
|
label: 'Clientes positivados',
|
||||||
|
data: valores,
|
||||||
|
backgroundColor: 'rgba(0, 74, 153, 0.8)',
|
||||||
|
borderRadius: 4,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
...(meta > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'line' as const,
|
||||||
|
label: `Meta (${meta}/dia)`,
|
||||||
|
data: labels.map(() => meta),
|
||||||
|
borderColor: '#f5222d',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 4],
|
||||||
|
pointRadius: 0,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index' as const, intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: (items: { label?: string }[]) => `Dia ${items[0]?.label ?? ''}`,
|
||||||
|
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
||||||
|
const val = ctx.parsed.y;
|
||||||
|
if (val == null) return '';
|
||||||
|
return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { precision: 0, font: { size: 10 } },
|
||||||
|
grid: { color: '#f0f0f0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faCalendarDay} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Positivação Diária de Clientes — {periodoLabel}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
{meta > 0 && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{diasComMeta} de {ultimoDia} dia{ultimoDia !== 1 ? 's' : ''} na meta
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
Meta/dia:
|
||||||
|
</Text>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
size="small"
|
||||||
|
value={meta > 0 ? meta : undefined}
|
||||||
|
onChange={changeMeta}
|
||||||
|
placeholder="—"
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ height: 280 }}>
|
||||||
|
<Chart type="bar" data={chartData} options={options} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GerPainel() {
|
||||||
|
const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
|
||||||
|
const mes = periodo.month() + 1;
|
||||||
|
const ano = periodo.year();
|
||||||
|
|
||||||
|
const { data, isLoading } = useManagerDashboard(mes, ano);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const periodoLabel = periodo
|
||||||
|
.toDate()
|
||||||
|
.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||||
|
const isMesAtual = mes === dayjs().month() + 1 && ano === dayjs().year();
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 1 }} />
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Col key={i} xs={24} sm={12} lg={6}>
|
||||||
|
<Skeleton active />
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
faturamentoMes,
|
||||||
|
pedidosMes,
|
||||||
|
ticketMedio,
|
||||||
|
totalReps,
|
||||||
|
promocoesAtivas,
|
||||||
|
metaTotal,
|
||||||
|
metasPorGrupo,
|
||||||
|
rankingReps,
|
||||||
|
positivacaoReps,
|
||||||
|
positivacaoDiaria,
|
||||||
|
syncedAt,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const pctMeta = metaTotal > 0 ? Math.round((faturamentoMes / metaTotal) * 100) : 0;
|
||||||
|
const faltaMeta = Math.max(0, metaTotal - faturamentoMes);
|
||||||
|
const superouMeta = faturamentoMes > metaTotal ? faturamentoMes - metaTotal : 0;
|
||||||
|
const metaCorColor =
|
||||||
|
pctMeta >= 100 ? 'var(--green)' : pctMeta >= 70 ? 'var(--jcs-blue)' : '#faad14';
|
||||||
|
|
||||||
|
// Dias restantes no período selecionado (incluindo hoje se for mês atual)
|
||||||
|
const hoje = dayjs();
|
||||||
|
const fimMes = periodo.endOf('month');
|
||||||
|
const diasRestantes = isMesAtual
|
||||||
|
? Math.max(0, fimMes.date() - hoje.date() + 1)
|
||||||
|
: periodo.isBefore(hoje, 'month')
|
||||||
|
? 0
|
||||||
|
: fimMes.date(); // mês futuro: todos os dias
|
||||||
|
const vendaPorDia = faltaMeta > 0 && diasRestantes > 0 ? faltaMeta / diasRestantes : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
{/* Cabeçalho */}
|
||||||
|
<Flex align="flex-start" justify="space-between" wrap="wrap" gap={12}>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
|
Painel Gerencial
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
|
{today()}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
Período:
|
||||||
|
</Text>
|
||||||
|
<DatePicker
|
||||||
|
picker="month"
|
||||||
|
value={periodo}
|
||||||
|
onChange={(d) => d && setPeriodo(d)}
|
||||||
|
format="MMM/YYYY"
|
||||||
|
allowClear={false}
|
||||||
|
disabledDate={(d) => d.isAfter(dayjs(), 'month')}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* KPIs linha 1 */}
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{fmt(faturamentoMes)}
|
||||||
|
</Title>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faFileInvoiceDollar}
|
||||||
|
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{pedidosMes}
|
||||||
|
</Title>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faChartBar}
|
||||||
|
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
TICKET MÉDIO
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{fmt(ticketMedio)}
|
||||||
|
</Title>
|
||||||
|
<FontAwesomeIcon icon={faUsers} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
PROMOÇÕES ATIVAS
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{promocoesAtivas}
|
||||||
|
</Title>
|
||||||
|
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* KPIs linha 2 — Meta */}
|
||||||
|
{metaTotal > 0 && (
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{fmt(metaTotal)}
|
||||||
|
</Title>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faBullseye}
|
||||||
|
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={8}>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
ATINGIMENTO DA META
|
||||||
|
</Text>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faArrowTrendUp}
|
||||||
|
style={{ color: metaCorColor, opacity: 0.7 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Title
|
||||||
|
level={3}
|
||||||
|
style={{ margin: 0, color: metaCorColor }}
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{pctMeta}%
|
||||||
|
</Title>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(pctMeta, 100)}
|
||||||
|
strokeColor={metaCorColor}
|
||||||
|
showInfo={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
|
||||||
|
</Text>
|
||||||
|
<Title
|
||||||
|
level={3}
|
||||||
|
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
|
||||||
|
</Title>
|
||||||
|
{superouMeta > 0 ? (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{pctMeta - 100}% acima da meta
|
||||||
|
</Text>
|
||||||
|
) : vendaPorDia > 0 ? (
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{fmt(vendaPorDia)}/dia — {diasRestantes} dia
|
||||||
|
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
) : diasRestantes === 0 ? (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
período encerrado
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metas por Grupo */}
|
||||||
|
{metasPorGrupo.length > 0 && (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faLayerGroup} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Metas por Grupo — {periodoLabel}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{metasPorGrupo.filter((g: MetaItem) => g.pct >= 100).length} de {metasPorGrupo.length}{' '}
|
||||||
|
grupo
|
||||||
|
{metasPorGrupo.length !== 1 ? 's' : ''} atingido
|
||||||
|
{metasPorGrupo.filter((g: MetaItem) => g.pct >= 100).length !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<MetaItem>
|
||||||
|
rowKey={(r) => String(r.codigo ?? r.rotulo)}
|
||||||
|
columns={metasGrupoColumns}
|
||||||
|
dataSource={metasPorGrupo}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhuma meta por grupo cadastrada.' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ranking */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faChartBar} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Ranking de Representantes — {periodoLabel}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Flex align="center" gap={12}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
top 10 de {totalReps} rep{totalReps !== 1 ? 's' : ''} com pedidos
|
||||||
|
</Text>
|
||||||
|
<Button size="small" onClick={() => void navigate({ to: '/ger/equipe' })}>
|
||||||
|
Ver todos
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<RankingRep>
|
||||||
|
rowKey="codVendedor"
|
||||||
|
columns={rankingColumns}
|
||||||
|
dataSource={rankingReps}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum pedido registrado neste período.' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Positivação Diária */}
|
||||||
|
<PositivacaoDiariaCard
|
||||||
|
dados={positivacaoDiaria}
|
||||||
|
periodo={periodo}
|
||||||
|
periodoLabel={periodoLabel}
|
||||||
|
isMesAtual={isMesAtual}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Positivação por Representante */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faAddressCard} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Positivação de Clientes por Representante — {periodoLabel}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<PositivacaoRep>
|
||||||
|
rowKey="codVendedor"
|
||||||
|
columns={positivacaoColumns}
|
||||||
|
dataSource={positivacaoReps}
|
||||||
|
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum dado de carteira encontrado.' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Flex justify="space-between" style={{ paddingTop: 8 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
SAR · Força de Vendas · Powered by JCS Sistemas
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')}
|
||||||
|
{isMesAtual && ' · atualiza a cada 60s'}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
711
apps/web/src/cockpits/ger/PoliticasPage.tsx
Normal file
711
apps/web/src/cockpits/ger/PoliticasPage.tsx
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DatePicker,
|
||||||
|
Flex,
|
||||||
|
Form,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
Input,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type {
|
||||||
|
AlcadaDescontoItem,
|
||||||
|
Promocao,
|
||||||
|
RegraDesconto,
|
||||||
|
RepStats,
|
||||||
|
GrupoProdutoItem,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import {
|
||||||
|
usePoliticasDescontos,
|
||||||
|
usePromocoes,
|
||||||
|
useUpsertDesconto,
|
||||||
|
useCreatePromocao,
|
||||||
|
useUpdatePromocao,
|
||||||
|
useDeletePromocao,
|
||||||
|
useEquipe,
|
||||||
|
useRegrasDesconto,
|
||||||
|
useGruposProduto,
|
||||||
|
useCreateRegraDesconto,
|
||||||
|
useUpdateRegraDesconto,
|
||||||
|
useDeleteRegraDesconto,
|
||||||
|
} from '../../lib/queries/gerente';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
// ─── Tab Descontos ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TabDescontos() {
|
||||||
|
const { data, isLoading } = usePoliticasDescontos();
|
||||||
|
const upsert = useUpsertDesconto();
|
||||||
|
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState<number>(0);
|
||||||
|
const [msg, msgCtx] = message.useMessage();
|
||||||
|
|
||||||
|
function startEdit(row: AlcadaDescontoItem) {
|
||||||
|
setEditingKey(`${row.codVendedor}-${row.codGrupo}`);
|
||||||
|
setEditValue(row.limitePerc);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(row: AlcadaDescontoItem) {
|
||||||
|
try {
|
||||||
|
await upsert.mutateAsync({
|
||||||
|
codVendedor: row.codVendedor,
|
||||||
|
codGrupo: row.codGrupo,
|
||||||
|
limitePerc: editValue,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
setEditingKey(null);
|
||||||
|
void msg.success('Limite atualizado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<AlcadaDescontoItem> = [
|
||||||
|
{
|
||||||
|
title: 'Representante',
|
||||||
|
key: 'rep',
|
||||||
|
render: (_: unknown, row: AlcadaDescontoItem) =>
|
||||||
|
row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Grupo',
|
||||||
|
dataIndex: 'codGrupo',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number) => (v === 0 ? <Tag>Global</Tag> : <Tag color="blue">Grupo {v}</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Limite de Desconto',
|
||||||
|
dataIndex: 'limitePerc',
|
||||||
|
width: 200,
|
||||||
|
render: (v: number, row: AlcadaDescontoItem) => {
|
||||||
|
const key = `${row.codVendedor}-${row.codGrupo}`;
|
||||||
|
if (editingKey === key) {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
value={editValue}
|
||||||
|
onChange={(val) => setEditValue(val ?? 0)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
suffix="%"
|
||||||
|
size="small"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Text className="tabular-nums">{v}%</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, row: AlcadaDescontoItem) => {
|
||||||
|
const key = `${row.codVendedor}-${row.codGrupo}`;
|
||||||
|
if (editingKey === key) {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
loading={upsert.isPending}
|
||||||
|
onClick={() => void saveEdit(row)}
|
||||||
|
>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => setEditingKey(null)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FontAwesomeIcon icon={faPen} />}
|
||||||
|
onClick={() => startEdit(row)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{msgCtx}
|
||||||
|
<Table<AlcadaDescontoItem>
|
||||||
|
rowKey={(r) => `${r.codVendedor}-${r.codGrupo}`}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.descontos}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum limite configurado.' }}
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
||||||
|
Limite 0 = sem restricao de desconto para este representante.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab Promocoes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PromocaoForm {
|
||||||
|
descricao: string;
|
||||||
|
codProduto?: string;
|
||||||
|
grpProd?: string;
|
||||||
|
descPct: number;
|
||||||
|
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabPromocoes() {
|
||||||
|
const { data, isLoading } = usePromocoes();
|
||||||
|
const createMutation = useCreatePromocao();
|
||||||
|
const updateMutation = useUpdatePromocao();
|
||||||
|
const deleteMutation = useDeletePromocao();
|
||||||
|
const [form] = Form.useForm<PromocaoForm>();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editTarget, setEditTarget] = useState<Promocao | null>(null);
|
||||||
|
const [msg, msgCtx] = message.useMessage();
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditTarget(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(p: Promocao) {
|
||||||
|
setEditTarget(p);
|
||||||
|
form.setFieldsValue({
|
||||||
|
descricao: p.descricao,
|
||||||
|
codProduto: p.codProduto ?? undefined,
|
||||||
|
grpProd: p.grpProd ?? undefined,
|
||||||
|
descPct: p.descPct,
|
||||||
|
periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)],
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(values: PromocaoForm) {
|
||||||
|
const [inicio, fim] = values.periodo;
|
||||||
|
const body = {
|
||||||
|
descricao: values.descricao,
|
||||||
|
codProduto: values.codProduto || undefined,
|
||||||
|
grpProd: values.grpProd || undefined,
|
||||||
|
descPct: values.descPct,
|
||||||
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editTarget) {
|
||||||
|
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
||||||
|
void msg.success('Promocao atualizada');
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(body);
|
||||||
|
void msg.success('Promocao criada');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
void msg.success('Promocao removida');
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const columns: TableColumnsType<Promocao> = [
|
||||||
|
{
|
||||||
|
title: 'Descricao',
|
||||||
|
dataIndex: 'descricao',
|
||||||
|
render: (v: string, row: Promocao) => (
|
||||||
|
<Flex vertical gap={2}>
|
||||||
|
<Text>{v}</Text>
|
||||||
|
{(row.codProduto || row.grpProd) && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{row.codProduto ? `Produto: ${row.codProduto}` : `Grupo: ${row.grpProd}`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Desconto',
|
||||||
|
dataIndex: 'descPct',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => <Text className="tabular-nums">{v}%</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vigencia',
|
||||||
|
width: 200,
|
||||||
|
render: (_: unknown, row: Promocao) => (
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
{dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, row: Promocao) => {
|
||||||
|
if (!row.ativa) return <Tag>Inativa</Tag>;
|
||||||
|
if (row.dataFim < today) return <Tag color="red">Expirada</Tag>;
|
||||||
|
if (row.dataInicio > today) return <Tag color="blue">Futura</Tag>;
|
||||||
|
return <Tag color="green">Ativa</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, row: Promocao) => (
|
||||||
|
<Space>
|
||||||
|
<Tooltip title="Editar">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FontAwesomeIcon icon={faPen} />}
|
||||||
|
onClick={() => openEdit(row)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm
|
||||||
|
title="Remover promocao?"
|
||||||
|
okText="Sim"
|
||||||
|
cancelText="Nao"
|
||||||
|
onConfirm={() => void handleDelete(row.id)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{msgCtx}
|
||||||
|
<Flex justify="flex-end" style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<FontAwesomeIcon icon={faPlus} />} onClick={openCreate}>
|
||||||
|
Nova Promocao
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Table<Promocao>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.promocoes}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhuma promocao cadastrada.' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editTarget ? 'Editar Promocao' : 'Nova Promocao'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||||
|
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={520}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="descricao"
|
||||||
|
label="Descricao"
|
||||||
|
rules={[{ required: true, message: 'Informe a descricao' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Ex.: Promocao de inverno" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Flex gap={16}>
|
||||||
|
<Form.Item name="codProduto" label="Codigo do produto" style={{ flex: 1 }}>
|
||||||
|
<Input placeholder="Opcional" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
|
||||||
|
<Input placeholder="Opcional" />
|
||||||
|
</Form.Item>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="descPct"
|
||||||
|
label="Desconto (%)"
|
||||||
|
rules={[{ required: true, message: 'Informe o desconto' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={100} suffix="%" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="periodo"
|
||||||
|
label="Periodo de vigencia"
|
||||||
|
rules={[{ required: true, message: 'Informe o periodo' }]}
|
||||||
|
>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
format="DD/MM/YYYY"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={['Inicio', 'Fim']}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab Regras de Desconto ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RegraForm {
|
||||||
|
descricao: string;
|
||||||
|
codVendedor?: number;
|
||||||
|
codGrupo?: number;
|
||||||
|
codSubgrupo?: number;
|
||||||
|
descPct: number;
|
||||||
|
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabRegras() {
|
||||||
|
const { data, isLoading } = useRegrasDesconto();
|
||||||
|
const { data: equipe } = useEquipe();
|
||||||
|
const { data: gruposData } = useGruposProduto();
|
||||||
|
const createMutation = useCreateRegraDesconto();
|
||||||
|
const updateMutation = useUpdateRegraDesconto();
|
||||||
|
const deleteMutation = useDeleteRegraDesconto();
|
||||||
|
const [form] = Form.useForm<RegraForm>();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null);
|
||||||
|
const [msg, msgCtx] = message.useMessage();
|
||||||
|
const grupoSelecionado = Form.useWatch('codGrupo', form);
|
||||||
|
|
||||||
|
const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
|
||||||
|
value: r.codVendedor,
|
||||||
|
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
|
||||||
|
const grupoMap = new Map<number, { value: number; label: string }>();
|
||||||
|
const subgrupoMap = new Map<number, { value: number; label: string }>();
|
||||||
|
for (const g of grupoRows) {
|
||||||
|
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) {
|
||||||
|
grupoMap.set(g.codGrupo, { value: g.codGrupo, label: g.grupo ?? `Grupo ${g.codGrupo}` });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
g.codSubgrupo != null &&
|
||||||
|
(grupoSelecionado == null || g.codGrupo === grupoSelecionado) &&
|
||||||
|
!subgrupoMap.has(g.codSubgrupo)
|
||||||
|
) {
|
||||||
|
subgrupoMap.set(g.codSubgrupo, {
|
||||||
|
value: g.codSubgrupo,
|
||||||
|
label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const grupoOptions = [...grupoMap.values()];
|
||||||
|
const subgrupoOptions = [...subgrupoMap.values()];
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditTarget(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(r: RegraDesconto) {
|
||||||
|
setEditTarget(r);
|
||||||
|
form.setFieldsValue({
|
||||||
|
descricao: r.descricao,
|
||||||
|
codVendedor: r.codVendedor ?? undefined,
|
||||||
|
codGrupo: r.codGrupo ?? undefined,
|
||||||
|
codSubgrupo: r.codSubgrupo ?? undefined,
|
||||||
|
descPct: r.descPct,
|
||||||
|
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(values: RegraForm) {
|
||||||
|
const [inicio, fim] = values.periodo;
|
||||||
|
const body = {
|
||||||
|
descricao: values.descricao,
|
||||||
|
codVendedor: values.codVendedor ?? null,
|
||||||
|
codGrupo: values.codGrupo ?? null,
|
||||||
|
codSubgrupo: values.codSubgrupo ?? null,
|
||||||
|
descPct: values.descPct,
|
||||||
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editTarget) {
|
||||||
|
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
||||||
|
void msg.success('Regra atualizada');
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(body);
|
||||||
|
void msg.success('Regra criada');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
void msg.success('Regra removida');
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const columns: TableColumnsType<RegraDesconto> = [
|
||||||
|
{
|
||||||
|
title: 'Descricao',
|
||||||
|
dataIndex: 'descricao',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Representante',
|
||||||
|
width: 180,
|
||||||
|
render: (_: unknown, row: RegraDesconto) =>
|
||||||
|
row.codVendedor == null ? (
|
||||||
|
<Tag>Todos</Tag>
|
||||||
|
) : (
|
||||||
|
<Text>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Grupo / Subgrupo',
|
||||||
|
width: 220,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => {
|
||||||
|
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
|
||||||
|
const partes = [
|
||||||
|
row.codGrupo != null ? (row.grupo ?? `Grupo ${row.codGrupo}`) : null,
|
||||||
|
row.codSubgrupo != null ? (row.subgrupo ?? `Subgrupo ${row.codSubgrupo}`) : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Desconto',
|
||||||
|
dataIndex: 'descPct',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => <Text className="tabular-nums">{v}%</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vigencia',
|
||||||
|
width: 200,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => (
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
{dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => {
|
||||||
|
if (!row.ativa) return <Tag>Inativa</Tag>;
|
||||||
|
if (row.dataFim < today) return <Tag color="red">Expirada</Tag>;
|
||||||
|
if (row.dataInicio > today) return <Tag color="blue">Futura</Tag>;
|
||||||
|
return <Tag color="green">Ativa</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => (
|
||||||
|
<Space>
|
||||||
|
<Tooltip title="Editar">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FontAwesomeIcon icon={faPen} />}
|
||||||
|
onClick={() => openEdit(row)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm
|
||||||
|
title="Remover regra?"
|
||||||
|
okText="Sim"
|
||||||
|
cancelText="Nao"
|
||||||
|
onConfirm={() => void handleDelete(row.id)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{msgCtx}
|
||||||
|
<Flex justify="flex-end" style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<FontAwesomeIcon icon={faPlus} />} onClick={openCreate}>
|
||||||
|
Nova Regra
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Table<RegraDesconto>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.regras}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
||||||
|
A regra vale para o representante, grupo e subgrupo informados (em branco = todos) e aplica
|
||||||
|
o desconto automaticamente nos pedidos durante a vigencia.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editTarget ? 'Editar Regra de Desconto' : 'Nova Regra de Desconto'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||||
|
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={520}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="descricao"
|
||||||
|
label="Descricao"
|
||||||
|
rules={[{ required: true, message: 'Informe a descricao' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Ex.: Campanha linha inverno" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="codVendedor" label="Representante">
|
||||||
|
<Select
|
||||||
|
options={repOptions}
|
||||||
|
placeholder="Todos os representantes"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Flex gap={16}>
|
||||||
|
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||||
|
<Select
|
||||||
|
options={grupoOptions}
|
||||||
|
placeholder="Todos os grupos"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
|
||||||
|
<Select
|
||||||
|
options={subgrupoOptions}
|
||||||
|
placeholder="Todos os subgrupos"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="descPct"
|
||||||
|
label="Desconto (%)"
|
||||||
|
rules={[{ required: true, message: 'Informe o desconto' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={100} suffix="%" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="periodo"
|
||||||
|
label="Periodo de vigencia"
|
||||||
|
rules={[{ required: true, message: 'Informe o periodo' }]}
|
||||||
|
>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
format="DD/MM/YYYY"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={['Inicio', 'Fim']}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PoliticasPage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function PoliticasPage() {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: 'descontos',
|
||||||
|
label: 'Desconto por Representante',
|
||||||
|
children: <TabDescontos />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'promocoes',
|
||||||
|
label: 'Promocoes',
|
||||||
|
children: <TabPromocoes />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'regras',
|
||||||
|
label: 'Regras de Desconto',
|
||||||
|
children: <TabRegras />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
|
Politicas Comerciais
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
|
Limites de desconto e promocoes com vigencia
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Card styles={{ body: { paddingTop: 0 } }}>
|
||||||
|
<Tabs items={items} />
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
159
apps/web/src/cockpits/rep/AbrirChamadoModal.tsx
Normal file
159
apps/web/src/cockpits/rep/AbrirChamadoModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, Form, Select, Input, Checkbox, Typography, Space } from 'antd';
|
||||||
|
import { TIPOS_CHAMADO, TIPO_CHAMADO_LABEL, type PedidoItem } from '@sar/api-interface';
|
||||||
|
import { useCreateChamado } from '../../lib/queries/sac';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
idPedido?: string;
|
||||||
|
numPedSar?: number;
|
||||||
|
numPedErp?: number;
|
||||||
|
nomeCliente?: string | null;
|
||||||
|
idCliente: number;
|
||||||
|
itens: PedidoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AbrirChamadoModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
idPedido,
|
||||||
|
numPedSar,
|
||||||
|
numPedErp,
|
||||||
|
nomeCliente,
|
||||||
|
idCliente,
|
||||||
|
itens,
|
||||||
|
}: Props) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [itensSelecionados, setItensSelecionados] = useState<string[]>([]);
|
||||||
|
const criar = useCreateChamado();
|
||||||
|
|
||||||
|
const pedidoLabel = numPedErp
|
||||||
|
? `ERP #${numPedErp}`
|
||||||
|
: numPedSar
|
||||||
|
? `SAR-${String(numPedSar).padStart(5, '0')}`
|
||||||
|
: 'Pedido';
|
||||||
|
|
||||||
|
function handleOk() {
|
||||||
|
form.validateFields().then((values) => {
|
||||||
|
const itensAfetados = itens
|
||||||
|
.filter((it) => itensSelecionados.includes(it.id))
|
||||||
|
.map((it) => ({
|
||||||
|
codProduto: it.codProduto ?? '',
|
||||||
|
nomeProduto: it.descProduto ?? it.codProduto ?? '',
|
||||||
|
qtd: Number(it.qtd),
|
||||||
|
}));
|
||||||
|
criar.mutate(
|
||||||
|
{
|
||||||
|
idPedido,
|
||||||
|
numPedSar,
|
||||||
|
numPedErp,
|
||||||
|
nomeCliente: nomeCliente ?? undefined,
|
||||||
|
idCliente,
|
||||||
|
tipo: values.tipo,
|
||||||
|
assunto: values.assunto,
|
||||||
|
descricao: values.descricao,
|
||||||
|
itensAfetados,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
form.resetFields();
|
||||||
|
setItensSelecionados([]);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
form.resetFields();
|
||||||
|
setItensSelecionados([]);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
Abrir Chamado — {pedidoLabel}
|
||||||
|
{nomeCliente && (
|
||||||
|
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 8 }}>
|
||||||
|
· {nomeCliente}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
okText="Abrir Chamado"
|
||||||
|
cancelText="Cancelar"
|
||||||
|
confirmLoading={criar.isPending}
|
||||||
|
width={640}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="tipo"
|
||||||
|
label="Tipo de Ocorrência"
|
||||||
|
rules={[{ required: true, message: 'Selecione o tipo' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="Selecione...">
|
||||||
|
{TIPOS_CHAMADO.map((t) => (
|
||||||
|
<Select.Option key={t} value={t}>
|
||||||
|
{TIPO_CHAMADO_LABEL[t]}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="assunto"
|
||||||
|
label="Assunto"
|
||||||
|
rules={[{ required: true, min: 5, message: 'Mínimo 5 caracteres' }]}
|
||||||
|
>
|
||||||
|
<Input maxLength={200} showCount placeholder="Resumo breve do problema..." />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="descricao"
|
||||||
|
label="Descrição"
|
||||||
|
rules={[{ required: true, min: 10, message: 'Mínimo 10 caracteres' }]}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
maxLength={2000}
|
||||||
|
showCount
|
||||||
|
placeholder="Descreva detalhadamente o problema..."
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{itens.length > 0 && (
|
||||||
|
<Form.Item label="Produtos Afetados (opcional)">
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||||
|
{itens.map((it) => (
|
||||||
|
<Checkbox
|
||||||
|
key={it.id}
|
||||||
|
checked={itensSelecionados.includes(it.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setItensSelecionados((prev) => [...prev, it.id]);
|
||||||
|
} else {
|
||||||
|
setItensSelecionados((prev) => prev.filter((c) => c !== it.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
{it.codProduto} — {it.descProduto ?? it.codProduto}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary"> (Qtd: {it.qtd})</Text>
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
398
apps/web/src/cockpits/rep/CarteirePage.tsx
Normal file
398
apps/web/src/cockpits/rep/CarteirePage.tsx
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Flex,
|
||||||
|
Row,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
FireOutlined,
|
||||||
|
RiseOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import type { CarteiraCliente } from '@sar/api-interface';
|
||||||
|
import { useReportCarteira } from '../../lib/queries/reports';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
function fmt(v: number | string): string {
|
||||||
|
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(v: string | null | undefined): string {
|
||||||
|
if (!v) return '—';
|
||||||
|
return new Date(v + 'T00:00:00').toLocaleDateString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
type Filtro = 'todos' | 'ativos' | 'risco' | 'inativos' | 'semPedido';
|
||||||
|
|
||||||
|
function statusBadge(c: CarteiraCliente) {
|
||||||
|
if (c.totalPedidos === 0)
|
||||||
|
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedido</Text>} />;
|
||||||
|
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="#ef4444"
|
||||||
|
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="#f59e0b"
|
||||||
|
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Em risco 30d+</Text>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InsightCard({
|
||||||
|
icon,
|
||||||
|
cor,
|
||||||
|
titulo,
|
||||||
|
descricao,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
cor: string;
|
||||||
|
titulo: string;
|
||||||
|
descricao: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
styles={{ body: { padding: '14px 18px' } }}
|
||||||
|
style={{ borderLeft: `4px solid ${cor}`, borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<Flex gap={12} align="flex-start">
|
||||||
|
<span style={{ color: cor, fontSize: 18, marginTop: 2 }}>{icon}</span>
|
||||||
|
{/* minWidth: 0 — flex item precisa poder encolher; titulo/descricao
|
||||||
|
embutem razão social e valores que podem não ter ponto de quebra. */}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1e293b' }}>
|
||||||
|
{titulo}
|
||||||
|
</Text>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{descricao}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CarteirePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, isError, error } = useReportCarteira();
|
||||||
|
const [filtro, setFiltro] = useState<Filtro>('todos');
|
||||||
|
|
||||||
|
const clientes = data?.data ?? [];
|
||||||
|
|
||||||
|
const ativos = clientes.filter(
|
||||||
|
(c) => c.totalPedidos > 0 && (c.diasSemPedido == null || c.diasSemPedido < 30),
|
||||||
|
);
|
||||||
|
const emRisco = clientes.filter(
|
||||||
|
(c) => c.diasSemPedido != null && c.diasSemPedido >= 30 && c.diasSemPedido < 60,
|
||||||
|
);
|
||||||
|
const inativos = clientes.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60);
|
||||||
|
const semPedido = clientes.filter((c) => c.totalPedidos === 0);
|
||||||
|
|
||||||
|
const filtered = (() => {
|
||||||
|
if (filtro === 'ativos') return ativos;
|
||||||
|
if (filtro === 'risco') return emRisco;
|
||||||
|
if (filtro === 'inativos') return inativos;
|
||||||
|
if (filtro === 'semPedido') return semPedido;
|
||||||
|
return clientes;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Insights calculados
|
||||||
|
const top5Fat = clientes
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal))
|
||||||
|
.slice(0, 5);
|
||||||
|
const top5Pct = top5Fat.reduce((acc, c) => acc + c.participacaoPct, 0);
|
||||||
|
|
||||||
|
const maiorCliEmRisco = emRisco.sort(
|
||||||
|
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
|
||||||
|
)[0];
|
||||||
|
const maiorCliInativo = inativos.sort(
|
||||||
|
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const insights: { icon: React.ReactNode; cor: string; titulo: string; descricao: string }[] = [];
|
||||||
|
|
||||||
|
if (top5Pct >= 60) {
|
||||||
|
insights.push({
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
cor: '#f59e0b',
|
||||||
|
titulo: `Concentração: top 5 clientes = ${top5Pct.toFixed(0)}% do faturamento`,
|
||||||
|
descricao: 'Risco alto de dependência. Considere expandir outros clientes da carteira.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maiorCliEmRisco) {
|
||||||
|
const nome =
|
||||||
|
maiorCliEmRisco.razao ?? maiorCliEmRisco.nome ?? `Cliente ${maiorCliEmRisco.idCliente}`;
|
||||||
|
insights.push({
|
||||||
|
icon: <WarningOutlined />,
|
||||||
|
cor: '#f59e0b',
|
||||||
|
titulo: `${nome} está ${maiorCliEmRisco.diasSemPedido}d sem comprar`,
|
||||||
|
descricao: `Faturamento histórico: ${fmt(maiorCliEmRisco.faturamentoTotal)} — priorize o contato.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maiorCliInativo) {
|
||||||
|
const nome =
|
||||||
|
maiorCliInativo.razao ?? maiorCliInativo.nome ?? `Cliente ${maiorCliInativo.idCliente}`;
|
||||||
|
insights.push({
|
||||||
|
icon: <FireOutlined />,
|
||||||
|
cor: '#ef4444',
|
||||||
|
titulo: `${nome} inativo há ${maiorCliInativo.diasSemPedido}d`,
|
||||||
|
descricao: `Era um cliente de ${fmt(maiorCliInativo.faturamentoTotal)} — vale reativar.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (semPedido.length > 0) {
|
||||||
|
insights.push({
|
||||||
|
icon: <RiseOutlined />,
|
||||||
|
cor: '#003B8E',
|
||||||
|
titulo: `${semPedido.length} cliente${semPedido.length > 1 ? 's' : ''} sem nenhum pedido`,
|
||||||
|
descricao: 'Potencial inexplorado na sua carteira. Primeira visita pode gerar receita nova.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<CarteiraCliente> = [
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
key: 'cliente',
|
||||||
|
render: (_: unknown, c: CarteiraCliente) => (
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ fontSize: 13, cursor: 'pointer', color: '#003B8E' }}
|
||||||
|
onClick={() => navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })}
|
||||||
|
>
|
||||||
|
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
|
||||||
|
</Text>
|
||||||
|
{c.razao && c.nome && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{c.nome}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
key: 'status',
|
||||||
|
width: 140,
|
||||||
|
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Último Pedido',
|
||||||
|
dataIndex: 'ultimoPedido',
|
||||||
|
width: 130,
|
||||||
|
render: (v: string | null) => (
|
||||||
|
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => (a.ultimoPedido ?? '').localeCompare(b.ultimoPedido ?? ''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Dias Sem Compra',
|
||||||
|
dataIndex: 'diasSemPedido',
|
||||||
|
width: 140,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number | null) =>
|
||||||
|
v != null ? (
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
|
||||||
|
>
|
||||||
|
{v}d
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">—</Text>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => (a.diasSemPedido ?? 9999) - (b.diasSemPedido ?? 9999),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Faturado (total)',
|
||||||
|
dataIndex: 'faturamentoTotal',
|
||||||
|
width: 150,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => Number(a.faturamentoTotal) - Number(b.faturamentoTotal),
|
||||||
|
defaultSortOrder: 'descend' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Participação',
|
||||||
|
dataIndex: 'participacaoPct',
|
||||||
|
width: 110,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => (
|
||||||
|
<Tag
|
||||||
|
color={v >= 10 ? 'blue' : v >= 5 ? 'geekblue' : 'default'}
|
||||||
|
style={{ borderRadius: 20, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{v.toFixed(1)}%
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => a.participacaoPct - b.participacaoPct,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ticket Médio',
|
||||||
|
dataIndex: 'ticketMedio',
|
||||||
|
width: 130,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text className="tabular-nums" style={{ color: '#475569' }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => Number(a.ticketMedio) - Number(b.ticketMedio),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'totalPedidos',
|
||||||
|
width: 80,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
|
||||||
|
sorter: (a, b) => a.totalPedidos - b.totalPedidos,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtros: { key: Filtro; label: string; count: number; cor: string }[] = [
|
||||||
|
{ key: 'todos', label: 'Todos', count: clientes.length, cor: '#64748b' },
|
||||||
|
{ key: 'ativos', label: 'Ativos', count: ativos.length, cor: '#22c55e' },
|
||||||
|
{ key: 'risco', label: 'Em risco (30d+)', count: emRisco.length, cor: '#f59e0b' },
|
||||||
|
{ key: 'inativos', label: 'Inativos (60d+)', count: inativos.length, cor: '#ef4444' },
|
||||||
|
{ key: 'semPedido', label: 'Sem pedido', count: semPedido.length, cor: '#94A3B8' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||||
|
{/* Cabeçalho */}
|
||||||
|
<Flex align="center" gap={12} style={{ marginBottom: 24 }}>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate({ to: '/clientes' })}
|
||||||
|
type="text"
|
||||||
|
style={{ color: '#64748b' }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
<TeamOutlined style={{ marginRight: 8 }} />
|
||||||
|
Carteira de Clientes
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Visão completa da sua carteira com indicadores de saúde e oportunidades
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Erro ao carregar carteira"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 10 }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Cards de resumo */}
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||||
|
{filtros.map((f) => (
|
||||||
|
<Col key={f.key} xs={12} sm={8} md={4} style={{ minWidth: 120 }}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={() => setFiltro(f.key)}
|
||||||
|
styles={{ body: { padding: '12px 16px' } }}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: filtro === f.key ? `2px solid ${f.cor}` : '1px solid #EBF0F5',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={f.label}
|
||||||
|
value={f.count}
|
||||||
|
styles={{ content: { color: f.cor, fontSize: 24 } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
{data && (
|
||||||
|
<Col xs={12} sm={8} md={6}>
|
||||||
|
<Card styles={{ body: { padding: '12px 16px' } }} style={{ borderRadius: 8 }}>
|
||||||
|
<Statistic
|
||||||
|
title="Faturamento Total (ERP)"
|
||||||
|
value={fmt(data.faturamentoRepTotal)}
|
||||||
|
styles={{ content: { color: '#003B8E', fontSize: 18 } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Insights */}
|
||||||
|
{insights.length > 0 && (
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||||
|
{insights.map((ins, i) => (
|
||||||
|
<Col key={i} xs={24} md={12}>
|
||||||
|
<InsightCard {...ins} />
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabela */}
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table<CarteiraCliente>
|
||||||
|
rowKey="idCliente"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filtered}
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
style={{ borderRadius: 10, overflow: 'hidden' }}
|
||||||
|
onRow={(c) => ({
|
||||||
|
onClick: () =>
|
||||||
|
navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } }),
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
apps/web/src/cockpits/rep/CatalogPage.tsx
Normal file
192
apps/web/src/cockpits/rep/CatalogPage.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
|
||||||
|
import { EyeOutlined } from '@ant-design/icons';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import type { ProdutoSummary } from '@sar/api-interface';
|
||||||
|
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
||||||
|
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
|
||||||
|
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||||
|
import { ProductDetailDrawer } from './ProductDetailDrawer';
|
||||||
|
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { Search } = Input;
|
||||||
|
|
||||||
|
function fmtPrice(v: string | null | undefined): string {
|
||||||
|
const n = Number(v ?? 0);
|
||||||
|
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColumns(
|
||||||
|
onDetail: (id: number) => void,
|
||||||
|
condicoesDe: (p: ProdutoSummary) => CondicaoComercial[],
|
||||||
|
): TableColumnsType<ProdutoSummary> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Código',
|
||||||
|
dataIndex: 'codigo',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => <span style={{ fontVariantNumeric: 'tabular-nums' }}>{v.trim()}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Descrição',
|
||||||
|
dataIndex: 'descricao',
|
||||||
|
render: (v: string, row: ProdutoSummary) => (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v.trim()}
|
||||||
|
<CondicaoTag conds={condicoesDe(row)} compact />
|
||||||
|
</div>
|
||||||
|
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Und',
|
||||||
|
dataIndex: 'unidade',
|
||||||
|
width: 60,
|
||||||
|
align: 'center',
|
||||||
|
render: (v: string | null) => v ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Marca',
|
||||||
|
dataIndex: 'marca',
|
||||||
|
width: 130,
|
||||||
|
render: (v: string | null) => (v ? <Tag>{v.trim()}</Tag> : null),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Preço',
|
||||||
|
dataIndex: 'vlPreco1',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: string) => (
|
||||||
|
<span style={{ fontWeight: 600, color: Number(v) > 0 ? '#389e0d' : '#999' }}>
|
||||||
|
{fmtPrice(v)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Estoque',
|
||||||
|
dataIndex: 'qtdEstoque',
|
||||||
|
width: 90,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: string | null) => {
|
||||||
|
if (v == null) return '—';
|
||||||
|
const n = Number(v);
|
||||||
|
return (
|
||||||
|
<span style={{ color: n > 0 ? 'inherit' : '#f5222d' }}>{n.toLocaleString('pt-BR')}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 48,
|
||||||
|
align: 'center',
|
||||||
|
render: (_: unknown, row: ProdutoSummary) => (
|
||||||
|
<Tooltip title="Ver detalhes">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => onDetail(row.idErp)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CatalogPage() {
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [idPauta, setIdPauta] = useState<number | undefined>();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selectedIdErp, setSelectedIdErp] = useState<number | null>(null);
|
||||||
|
const limit = 50;
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
|
const { data: pautas, isLoading: pautasLoading } = usePautas();
|
||||||
|
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
|
||||||
|
const condicoesDe = useCondicoesComerciais();
|
||||||
|
|
||||||
|
const columns = buildColumns((id) => setSelectedIdErp(id), condicoesDe);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||||
|
{/* ── Cabeçalho ────────────────────────────────────────────────── */}
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
Catálogo de Produtos
|
||||||
|
</Title>
|
||||||
|
<Text style={{ color: '#64748B', fontSize: 14 }}>
|
||||||
|
Consulte produtos, preços por pauta e disponibilidade de estoque.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Filtros ──────────────────────────────────────────────────── */}
|
||||||
|
<Space style={{ marginBottom: 16, width: '100%' }} wrap>
|
||||||
|
<Search
|
||||||
|
placeholder="Buscar por código ou descrição..."
|
||||||
|
allowClear
|
||||||
|
style={{ width: isMobile ? '100%' : 300 }}
|
||||||
|
onSearch={(v) => {
|
||||||
|
setQ(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value) {
|
||||||
|
setQ('');
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Selecionar pauta de preços"
|
||||||
|
allowClear
|
||||||
|
loading={pautasLoading}
|
||||||
|
style={{ width: isMobile ? '100%' : 340 }}
|
||||||
|
onChange={(v) => {
|
||||||
|
setIdPauta(v as number | undefined);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={pautas?.map((p) => ({
|
||||||
|
value: p.idPauta,
|
||||||
|
label: `${p.codigo} — ${p.descricao}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table<ProdutoSummary>
|
||||||
|
rowKey="idErp"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize: limit,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: false,
|
||||||
|
showTotal: (t) => `${t.toLocaleString('pt-BR')} produtos`,
|
||||||
|
onChange: (p) => setPage(p),
|
||||||
|
}}
|
||||||
|
onRow={(row) => ({
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
onDoubleClick: () => setSelectedIdErp(row.idErp),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProductDetailDrawer idErp={selectedIdErp} onClose={() => setSelectedIdErp(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
432
apps/web/src/cockpits/rep/ChamadosPage.tsx
Normal file
432
apps/web/src/cockpits/rep/ChamadosPage.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge, Button, Modal, Input, Space, Spin, Table, Tag, Typography, Tabs, Form } from 'antd';
|
||||||
|
import { CustomerServiceOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import {
|
||||||
|
STATUS_CHAMADO_LABEL,
|
||||||
|
TIPO_CHAMADO_LABEL,
|
||||||
|
RESOLUCAO_CHAMADO_LABEL,
|
||||||
|
type Chamado,
|
||||||
|
type ItemAfetado,
|
||||||
|
type ChamadoMensagem,
|
||||||
|
type PedidoErpConsultaItem,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import {
|
||||||
|
useChamados,
|
||||||
|
useAddMensagemRep,
|
||||||
|
useCancelChamado,
|
||||||
|
useChamado,
|
||||||
|
} from '../../lib/queries/sac';
|
||||||
|
import { useOrderErpConsulta, useOrderDetail } from '../../lib/queries/orders';
|
||||||
|
import { AbrirChamadoModal } from './AbrirChamadoModal';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
aberto: 'blue',
|
||||||
|
em_analise: 'orange',
|
||||||
|
aguardando_rep: 'purple',
|
||||||
|
resolvido: 'green',
|
||||||
|
cancelado: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
function pedidoLabel(chamado: Chamado) {
|
||||||
|
if (chamado.numPedErp) return `ERP #${chamado.numPedErp}`;
|
||||||
|
if (chamado.numPedSar) return `SAR-${String(chamado.numPedSar).padStart(5, '0')}`;
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca pedido ERP por texto livre (cliente, número, etc.) e abre chamado
|
||||||
|
function BuscarPedidoModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [searchAtivo, setSearchAtivo] = useState('');
|
||||||
|
const [pedidoSelecionado, setPedidoSelecionado] = useState<PedidoErpConsultaItem | null>(null);
|
||||||
|
|
||||||
|
// idPedido é o ID interno do ERP (usado no endpoint /orders/erp/:id)
|
||||||
|
// numeroPedido é o número externo visível ao usuário
|
||||||
|
const erp_id = pedidoSelecionado?.idPedido ?? null;
|
||||||
|
const consulta = useOrderErpConsulta({ search: searchAtivo, limit: 20 }, !!searchAtivo);
|
||||||
|
const detalhe = useOrderDetail(erp_id ? `erp-${erp_id}` : undefined);
|
||||||
|
|
||||||
|
function buscar() {
|
||||||
|
const trimmed = searchInput.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setSearchAtivo(trimmed);
|
||||||
|
setPedidoSelecionado(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fechar() {
|
||||||
|
setSearchInput('');
|
||||||
|
setSearchAtivo('');
|
||||||
|
setPedidoSelecionado(null);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detalhe carregado — abre o formulário de chamado
|
||||||
|
if (detalhe.data && pedidoSelecionado) {
|
||||||
|
return (
|
||||||
|
<AbrirChamadoModal
|
||||||
|
open={true}
|
||||||
|
onClose={fechar}
|
||||||
|
numPedErp={pedidoSelecionado.numeroPedido}
|
||||||
|
nomeCliente={pedidoSelecionado.razaoCliente ?? pedidoSelecionado.nomeCliente}
|
||||||
|
idCliente={pedidoSelecionado.idCliente}
|
||||||
|
itens={detalhe.data.itens}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultados = consulta.data?.data ?? [];
|
||||||
|
const carregandoDetalhe = detalhe.isLoading;
|
||||||
|
|
||||||
|
const colunas: TableColumnsType<PedidoErpConsultaItem> = [
|
||||||
|
{ title: 'Pedido', dataIndex: 'numeroPedido', width: 90 },
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
render: (_: unknown, r: PedidoErpConsultaItem) => r.razaoCliente ?? r.nomeCliente ?? '—',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Data',
|
||||||
|
dataIndex: 'data',
|
||||||
|
width: 95,
|
||||||
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) =>
|
||||||
|
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Novo Chamado — Selecionar Pedido"
|
||||||
|
open={open}
|
||||||
|
onCancel={fechar}
|
||||||
|
footer={null}
|
||||||
|
width={680}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" style={{ width: '100%', marginTop: 16 }} size="middle">
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="Buscar por cliente, número do pedido..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onPressEnter={buscar}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={buscar}
|
||||||
|
loading={consulta.isLoading}
|
||||||
|
disabled={!searchInput.trim()}
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
|
||||||
|
{searchAtivo && (
|
||||||
|
<Table<PedidoErpConsultaItem>
|
||||||
|
rowKey="numeroPedido"
|
||||||
|
columns={colunas}
|
||||||
|
dataSource={resultados}
|
||||||
|
loading={consulta.isLoading || carregandoDetalhe}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ y: 320 }}
|
||||||
|
onRow={(r) => ({
|
||||||
|
onClick: () => (r.idPedido ? setPedidoSelecionado(r) : undefined),
|
||||||
|
style: {
|
||||||
|
cursor: r.idPedido ? 'pointer' : 'not-allowed',
|
||||||
|
opacity: r.idPedido ? 1 : 0.4,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChamadoDetalheModal({
|
||||||
|
id,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [texto, setTexto] = useState('');
|
||||||
|
const { data: chamado, isLoading } = useChamado(id);
|
||||||
|
const addMsg = useAddMensagemRep(id);
|
||||||
|
const cancel = useCancelChamado();
|
||||||
|
|
||||||
|
function enviar() {
|
||||||
|
if (!texto.trim()) return;
|
||||||
|
addMsg.mutate({ texto: texto.trim() }, { onSuccess: () => setTexto('') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fechado = chamado && ['resolvido', 'cancelado'].includes(chamado.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={chamado ? `Chamado #${chamado.id} — ${chamado.assunto}` : 'Chamado'}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={700}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{isLoading || !chamado ? (
|
||||||
|
<Spin />
|
||||||
|
) : (
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color={STATUS_COLOR[chamado.status]}>{STATUS_CHAMADO_LABEL[chamado.status]}</Tag>
|
||||||
|
<Tag>{TIPO_CHAMADO_LABEL[chamado.tipo] ?? chamado.tipo}</Tag>
|
||||||
|
{chamado.resolucao && (
|
||||||
|
<Tag color="green">
|
||||||
|
{RESOLUCAO_CHAMADO_LABEL[chamado.resolucao] ?? chamado.resolucao}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{pedidoLabel(chamado)}
|
||||||
|
{chamado.nomeCliente ? ` · ${chamado.nomeCliente}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Text type="secondary">{chamado.descricao}</Text>
|
||||||
|
|
||||||
|
{chamado.notaResolucao && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f6ffed',
|
||||||
|
border: '1px solid #b7eb8f',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '8px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong>Resolução: </Text>
|
||||||
|
<Text>{chamado.notaResolucao}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chamado.itensAfetados && chamado.itensAfetados.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Text strong style={{ fontSize: 12 }}>
|
||||||
|
Produtos afetados:{' '}
|
||||||
|
</Text>
|
||||||
|
{(chamado.itensAfetados as ItemAfetado[]).map((it, i) => (
|
||||||
|
<Tag key={`${it.codProduto}-${i}`}>
|
||||||
|
{it.codProduto} — {it.nomeProduto} ({it.qtd} un)
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
maxHeight: 320,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(chamado.mensagens ?? []).length === 0 ? (
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ textAlign: 'center', display: 'block', padding: '16px 0' }}
|
||||||
|
>
|
||||||
|
Sem mensagens ainda.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
((chamado.mensagens as ChamadoMensagem[]) ?? []).map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: m.autor === 'rep' ? 'flex-end' : 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '72%',
|
||||||
|
background: m.autor === 'rep' ? '#1677ff' : '#f5f5f5',
|
||||||
|
color: m.autor === 'rep' ? '#fff' : '#000',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 2 }}>
|
||||||
|
{m.autor === 'rep' ? 'Você' : 'Empresa'}
|
||||||
|
</div>
|
||||||
|
<div>{m.texto}</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4, textAlign: 'right' }}>
|
||||||
|
{new Date(m.createdAt).toLocaleString('pt-BR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!fechado && (
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="Nova mensagem">
|
||||||
|
<TextArea
|
||||||
|
rows={2}
|
||||||
|
value={texto}
|
||||||
|
onChange={(e) => setTexto(e.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={enviar}
|
||||||
|
loading={addMsg.isPending}
|
||||||
|
disabled={!texto.trim()}
|
||||||
|
>
|
||||||
|
Enviar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
onClick={() => cancel.mutate(id, { onSuccess: onClose })}
|
||||||
|
loading={cancel.isPending}
|
||||||
|
>
|
||||||
|
Cancelar Chamado
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<Chamado> = [
|
||||||
|
{ title: '#', dataIndex: 'id', width: 60 },
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 140,
|
||||||
|
render: (s: string) => (
|
||||||
|
<Tag color={STATUS_COLOR[s] ?? 'default'}>{STATUS_CHAMADO_LABEL[s] ?? s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tipo',
|
||||||
|
dataIndex: 'tipo',
|
||||||
|
width: 160,
|
||||||
|
render: (t: string) => TIPO_CHAMADO_LABEL[t] ?? t,
|
||||||
|
},
|
||||||
|
{ title: 'Assunto', dataIndex: 'assunto', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: 'Pedido',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, r: Chamado) => pedidoLabel(r),
|
||||||
|
},
|
||||||
|
{ title: 'Cliente', dataIndex: 'nomeCliente', width: 160, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: 'Data',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ChamadosPage() {
|
||||||
|
const { data, isLoading } = useChamados();
|
||||||
|
const [detalheId, setDetalheId] = useState<number | null>(null);
|
||||||
|
const [novoOpen, setNovoOpen] = useState(false);
|
||||||
|
|
||||||
|
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||||
|
|
||||||
|
const abertos = data?.abertos ?? [];
|
||||||
|
const fechados = data?.fechados ?? [];
|
||||||
|
const totalAbertos = abertos.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, maxWidth: 1100, margin: '0 auto' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space align="center">
|
||||||
|
<CustomerServiceOutlined style={{ fontSize: 24, color: '#1677ff' }} />
|
||||||
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
|
SAC — Chamados
|
||||||
|
</Title>
|
||||||
|
{totalAbertos > 0 && <Badge count={totalAbertos} />}
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNovoOpen(true)}>
|
||||||
|
Novo Chamado
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'abertos',
|
||||||
|
label: `Abertos (${abertos.length})`,
|
||||||
|
children: (
|
||||||
|
<Table<Chamado>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={abertos}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||||
|
locale={{ emptyText: 'Nenhum chamado aberto.' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fechados',
|
||||||
|
label: `Fechados (${fechados.length})`,
|
||||||
|
children: (
|
||||||
|
<Table<Chamado>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={fechados}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
size="small"
|
||||||
|
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||||
|
locale={{ emptyText: 'Nenhum chamado fechado.' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{detalheId !== null && (
|
||||||
|
<ChamadoDetalheModal
|
||||||
|
id={detalheId}
|
||||||
|
open={detalheId !== null}
|
||||||
|
onClose={() => setDetalheId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<BuscarPedidoModal open={novoOpen} onClose={() => setNovoOpen(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
587
apps/web/src/cockpits/rep/ClientDetailPage.tsx
Normal file
587
apps/web/src/cockpits/rep/ClientDetailPage.tsx
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Grid,
|
||||||
|
Tag,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
Spin,
|
||||||
|
Alert,
|
||||||
|
Space,
|
||||||
|
Divider,
|
||||||
|
Modal,
|
||||||
|
Tooltip,
|
||||||
|
message,
|
||||||
|
Badge,
|
||||||
|
} from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||||
|
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
||||||
|
import type { CtrTitulo, NotaFiscal, PedidoSummary, TopProduto } from '@sar/api-interface';
|
||||||
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
|
import {
|
||||||
|
useClientDetail,
|
||||||
|
useClientCtr,
|
||||||
|
useClientCtrList,
|
||||||
|
useClientNotasFiscais,
|
||||||
|
useClientOrdersHistory,
|
||||||
|
useClientTopProdutos,
|
||||||
|
} from '../../lib/queries/clients';
|
||||||
|
import { ClientContacts } from '../../components/contacts/ClientContacts';
|
||||||
|
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const ACTIVITY_COLOR: Record<string, string> = {
|
||||||
|
active: 'success',
|
||||||
|
alert: 'warning',
|
||||||
|
inactive: 'default',
|
||||||
|
};
|
||||||
|
const ACTIVITY_LABEL: Record<string, string> = {
|
||||||
|
active: 'Ativo',
|
||||||
|
alert: 'Alerta',
|
||||||
|
inactive: 'Inativo',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SITUA_COLOR: Record<number, string> = {
|
||||||
|
0: 'default',
|
||||||
|
1: 'warning',
|
||||||
|
2: 'processing',
|
||||||
|
3: 'error',
|
||||||
|
4: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
function orderColumns(withFonte: boolean): TableColumnsType<PedidoSummary> {
|
||||||
|
const cols: TableColumnsType<PedidoSummary> = [
|
||||||
|
{
|
||||||
|
title: 'Nº Pedido',
|
||||||
|
dataIndex: 'numPedSar',
|
||||||
|
render: (num: string, row: PedidoSummary) => (
|
||||||
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
|
{num}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'situa',
|
||||||
|
width: 130,
|
||||||
|
render: (s: number) => (
|
||||||
|
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
width: 130,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) =>
|
||||||
|
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Data',
|
||||||
|
dataIndex: 'dtPedido',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (withFonte) {
|
||||||
|
cols.push({
|
||||||
|
title: 'Origem',
|
||||||
|
dataIndex: 'fonte',
|
||||||
|
width: 70,
|
||||||
|
render: (f: string) => (
|
||||||
|
<Tag color={f === 'sar' ? 'blue' : 'default'} style={{ fontSize: 11 }}>
|
||||||
|
{f === 'sar' ? 'SAR' : 'ERP'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientDetailPage() {
|
||||||
|
const { id } = useParams({ from: '/clientes/$id' });
|
||||||
|
const idNum = Number(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
const [addContactOpen, setAddContactOpen] = useState(false);
|
||||||
|
const [ordersModalOpen, setOrdersModalOpen] = useState(false);
|
||||||
|
const [produtosModalOpen, setProdutosModalOpen] = useState(false);
|
||||||
|
const [notasModalOpen, setNotasModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum);
|
||||||
|
const { data: ctr } = useClientCtr(idNum);
|
||||||
|
const { data: ctrList = [], isLoading: ctrListLoading } = useClientCtrList(idNum, 60);
|
||||||
|
const { data: orders = [], isLoading: ordersLoading } = useClientOrdersHistory(idNum, 50);
|
||||||
|
const { data: topProdutos = [], isLoading: produtosLoading } = useClientTopProdutos(idNum, 30);
|
||||||
|
const { data: notas = [], isLoading: notasLoading } = useClientNotasFiscais(idNum, 30);
|
||||||
|
|
||||||
|
const [msgApi, msgCtx] = message.useMessage();
|
||||||
|
|
||||||
|
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||||
|
if (clientError || !client)
|
||||||
|
return <Alert type="error" title="Cliente não encontrado." style={{ margin: 24 }} />;
|
||||||
|
|
||||||
|
const today: string = new Date().toISOString().slice(0, 10);
|
||||||
|
const ordersPreview = orders.slice(0, 5);
|
||||||
|
const produtosPreview = topProdutos.slice(0, 5);
|
||||||
|
const notasPreview = notas.slice(0, 5);
|
||||||
|
const ctrPreview = ctrList.slice(0, 8);
|
||||||
|
|
||||||
|
const notaColumns: TableColumnsType<NotaFiscal> = [
|
||||||
|
{
|
||||||
|
title: 'NF',
|
||||||
|
dataIndex: 'numero',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number, r: NotaFiscal) => `${v}-${r.serie}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Emissão',
|
||||||
|
dataIndex: 'dtEmissao',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Valor',
|
||||||
|
dataIndex: 'vlNf',
|
||||||
|
width: 130,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Chave NF-e',
|
||||||
|
dataIndex: 'chaveAcessoNfe',
|
||||||
|
render: (chave: string | null) =>
|
||||||
|
chave ? (
|
||||||
|
<Space size={4}>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 11, fontFamily: 'monospace', color: '#666' }}
|
||||||
|
ellipsis={{ tooltip: chave }}
|
||||||
|
>
|
||||||
|
{chave}
|
||||||
|
</Text>
|
||||||
|
<Tooltip title="Copiar chave">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(chave)
|
||||||
|
.then(() => void msgApi.success('Chave copiada!'))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const produtoColumns: TableColumnsType<TopProduto> = [
|
||||||
|
{
|
||||||
|
title: 'Produto',
|
||||||
|
dataIndex: 'descricao',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Qtd Total',
|
||||||
|
dataIndex: 'qtdTotal',
|
||||||
|
width: 100,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number, r: TopProduto) =>
|
||||||
|
`${v.toLocaleString('pt-BR', { maximumFractionDigits: 0 })} ${r.unidade ?? ''}`.trim(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'numPedidos',
|
||||||
|
width: 80,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Último Preço',
|
||||||
|
dataIndex: 'ultimoPreco',
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) =>
|
||||||
|
v > 0 ? v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Última Compra',
|
||||||
|
dataIndex: 'ultimaCompra',
|
||||||
|
width: 120,
|
||||||
|
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, maxWidth: '100%', overflowX: 'hidden' }}>
|
||||||
|
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Link to="/clientes">← Clientes</Link>
|
||||||
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
|
{client.razao ?? client.nome}
|
||||||
|
</Title>
|
||||||
|
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
|
||||||
|
{ACTIVITY_LABEL[client.activityStatus]}
|
||||||
|
</Tag>
|
||||||
|
<Button icon={<UserAddOutlined />} onClick={() => setAddContactOpen(true)}>
|
||||||
|
Novo Contato
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
|
||||||
|
>
|
||||||
|
Novo Pedido
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Descriptions bordered size="small" column={isMobile ? 1 : 2} style={{ marginBottom: 24 }}>
|
||||||
|
<Descriptions.Item label="Razão Social">{client.nome}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="CNPJ / CPF">{client.cgcpf ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Telefone">
|
||||||
|
{client.ddd ? `(${client.ddd}) ` : ''}
|
||||||
|
{client.telefone ?? '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
{client.endereco && (
|
||||||
|
<Descriptions.Item label="Endereço" span={isMobile ? 1 : 2}>
|
||||||
|
{client.endereco}
|
||||||
|
{client.numEndereco ? `, ${client.numEndereco}` : ''}
|
||||||
|
{client.bairro ? ` — ${client.bairro}` : ''}
|
||||||
|
{client.cep ? ` — CEP ${client.cep}` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="Limite de Crédito">
|
||||||
|
{client.limiteCreditoStr
|
||||||
|
? Number(client.limiteCreditoStr).toLocaleString('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL',
|
||||||
|
})
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Última Compra">
|
||||||
|
{client.dtUltimaCompra
|
||||||
|
? new Date(client.dtUltimaCompra).toLocaleDateString('pt-BR')
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
{/* ── CTR + NF-e lado a lado ── */}
|
||||||
|
{msgCtx}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Contas a Receber ── */}
|
||||||
|
{/* minWidth: 0 — track 1fr tem min-width auto; sem isto a tabela com
|
||||||
|
scroll x 'max-content' estoura a coluna e engole a vizinha (NF-e). */}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space size={8}>
|
||||||
|
<Text strong style={{ fontSize: 14 }}>
|
||||||
|
Contas a Receber
|
||||||
|
</Text>
|
||||||
|
{ctr && ctr.qtdAberto > 0 && (
|
||||||
|
<Tag color={ctr.qtdVencido > 0 ? 'error' : 'warning'}>
|
||||||
|
{ctr.qtdAberto} título{ctr.qtdAberto > 1 ? 's' : ''} —{' '}
|
||||||
|
{ctr.totalAberto.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '0 0 10px' }} />
|
||||||
|
<Table<CtrTitulo>
|
||||||
|
rowKey="idCtr"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
loading={ctrListLoading}
|
||||||
|
dataSource={ctrPreview}
|
||||||
|
locale={{ emptyText: 'Nenhum título em aberto.' }}
|
||||||
|
scroll={{ x: 'max-content', y: ctrList.length > 8 ? 260 : undefined }}
|
||||||
|
style={{ maxWidth: '100%' }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'Título / Parcela',
|
||||||
|
dataIndex: 'numero',
|
||||||
|
render: (num: string, r: CtrTitulo) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Text style={{ fontSize: 12 }}>
|
||||||
|
{r.prefixo}-{num}
|
||||||
|
</Text>
|
||||||
|
{r.dtVencimento < today ? (
|
||||||
|
<Badge
|
||||||
|
status="error"
|
||||||
|
text={
|
||||||
|
<Text type="danger" style={{ fontSize: 11 }}>
|
||||||
|
{Math.ceil(
|
||||||
|
(new Date(today).getTime() - new Date(r.dtVencimento).getTime()) /
|
||||||
|
86400000,
|
||||||
|
)}
|
||||||
|
d atraso
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
status="warning"
|
||||||
|
text={
|
||||||
|
<Text type="warning" style={{ fontSize: 11 }}>
|
||||||
|
a vencer
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vencimento',
|
||||||
|
dataIndex: 'dtVencimento',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text type={v < today ? 'danger' : undefined} style={{ fontSize: 12 }}>
|
||||||
|
{new Date(v + 'T12:00:00').toLocaleDateString('pt-BR')}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Saldo',
|
||||||
|
dataIndex: 'saldo',
|
||||||
|
width: 110,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => (
|
||||||
|
<Text strong style={{ fontSize: 12 }}>
|
||||||
|
{v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
footer={
|
||||||
|
ctrList.length > 8
|
||||||
|
? () => (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Exibindo 8 de {ctrList.length}
|
||||||
|
{ctrList.length === 60 ? '+' : ''} títulos • total em aberto:{' '}
|
||||||
|
{ctr?.totalAberto.toLocaleString('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Notas Fiscais ── */}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: 14 }}>
|
||||||
|
Notas Fiscais
|
||||||
|
</Text>
|
||||||
|
{notas.length > 5 && (
|
||||||
|
<Button size="small" type="link" onClick={() => setNotasModalOpen(true)}>
|
||||||
|
Ver todas ({notas.length}
|
||||||
|
{notas.length === 30 ? '+' : ''})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '0 0 10px' }} />
|
||||||
|
<Table<NotaFiscal>
|
||||||
|
rowKey="idNf"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
loading={notasLoading}
|
||||||
|
dataSource={notasPreview}
|
||||||
|
locale={{ emptyText: 'Nenhuma nota fiscal.' }}
|
||||||
|
style={{ maxWidth: '100%' }}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
columns={notaColumns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>Notas Fiscais — {client.razao ?? client.nome}</span>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
({notas.length}
|
||||||
|
{notas.length === 30 ? '+' : ''} notas)
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={notasModalOpen}
|
||||||
|
onCancel={() => setNotasModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={isMobile ? '95vw' : 900}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Table<NotaFiscal>
|
||||||
|
rowKey="idNf"
|
||||||
|
columns={notaColumns}
|
||||||
|
dataSource={notas}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
|
||||||
|
size="small"
|
||||||
|
scroll={{ y: 460, x: 'max-content' }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ClientContacts
|
||||||
|
idCliente={idNum}
|
||||||
|
modalOpen={addContactOpen}
|
||||||
|
onModalClose={() => setAddContactOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Produtos Mais Comprados ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: 14 }}>
|
||||||
|
Produtos Mais Comprados
|
||||||
|
</Text>
|
||||||
|
{topProdutos.length > 5 && (
|
||||||
|
<Button size="small" type="link" onClick={() => setProdutosModalOpen(true)}>
|
||||||
|
Ver todos ({topProdutos.length}
|
||||||
|
{topProdutos.length === 30 ? '+' : ''})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '0 0 12px' }} />
|
||||||
|
|
||||||
|
<Table<TopProduto>
|
||||||
|
rowKey="idProduto"
|
||||||
|
columns={produtoColumns}
|
||||||
|
dataSource={produtosPreview}
|
||||||
|
loading={produtosLoading}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum produto encontrado.' }}
|
||||||
|
style={{ marginBottom: 24, maxWidth: '100%' }}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>Produtos — {client.razao ?? client.nome}</span>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
({topProdutos.length}
|
||||||
|
{topProdutos.length === 30 ? '+' : ''} produtos)
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={produtosModalOpen}
|
||||||
|
onCancel={() => setProdutosModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={isMobile ? '95vw' : 820}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Table<TopProduto>
|
||||||
|
rowKey="idProduto"
|
||||||
|
columns={produtoColumns}
|
||||||
|
dataSource={topProdutos}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
|
||||||
|
size="small"
|
||||||
|
scroll={{ y: 460 }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Últimos Pedidos ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: 14 }}>
|
||||||
|
Últimos Pedidos
|
||||||
|
</Text>
|
||||||
|
{orders.length > 5 && (
|
||||||
|
<Button size="small" type="link" onClick={() => setOrdersModalOpen(true)}>
|
||||||
|
Ver todos ({orders.length}
|
||||||
|
{orders.length === 50 ? '+' : ''})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '0 0 12px' }} />
|
||||||
|
|
||||||
|
<Table<PedidoSummary>
|
||||||
|
rowKey="id"
|
||||||
|
columns={orderColumns(false)}
|
||||||
|
dataSource={ordersPreview}
|
||||||
|
loading={ordersLoading}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
|
||||||
|
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
|
||||||
|
style={{ marginBottom: 24, maxWidth: '100%' }}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
/>
|
||||||
|
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
|
||||||
|
|
||||||
|
{/* ── Modal Ver Todos Pedidos ── */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>Pedidos — {client.razao ?? client.nome}</span>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
({orders.length}
|
||||||
|
{orders.length === 50 ? '+' : ''} registros)
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={ordersModalOpen}
|
||||||
|
onCancel={() => setOrdersModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={isMobile ? '95vw' : 780}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Table<PedidoSummary>
|
||||||
|
rowKey="id"
|
||||||
|
columns={orderColumns(true)}
|
||||||
|
dataSource={orders}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
|
||||||
|
size="small"
|
||||||
|
scroll={{ y: 460 }}
|
||||||
|
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1609
apps/web/src/cockpits/rep/ClientsPage.tsx
Normal file
1609
apps/web/src/cockpits/rep/ClientsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
552
apps/web/src/cockpits/rep/FunilPage.tsx
Normal file
552
apps/web/src/cockpits/rep/FunilPage.tsx
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Collapse,
|
||||||
|
Flex,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ArrowRightOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type {
|
||||||
|
Oportunidade,
|
||||||
|
EtapaFunil,
|
||||||
|
CreateOportunidadeDto,
|
||||||
|
UpdateOportunidadeDto,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { ETAPAS_FUNIL } from '@sar/api-interface';
|
||||||
|
import {
|
||||||
|
useFunil,
|
||||||
|
useCreateOportunidade,
|
||||||
|
useUpdateOportunidade,
|
||||||
|
useDeleteOportunidade,
|
||||||
|
} from '../../lib/queries/funil';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const ETAPA_CONFIG: Record<EtapaFunil, { label: string; cor: string; corBg: string }> = {
|
||||||
|
lead: { label: 'Lead', cor: '#64748b', corBg: '#F8FAFC' },
|
||||||
|
proposta: { label: 'Proposta', cor: '#2563EB', corBg: '#EFF6FF' },
|
||||||
|
negociacao: { label: 'Negociação', cor: '#D97706', corBg: '#FFFBEB' },
|
||||||
|
ganho: { label: 'Ganho', cor: '#16A34A', corBg: '#F0FDF4' },
|
||||||
|
perdido: { label: 'Perdido', cor: '#DC2626', corBg: '#FEF2F2' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROXIMA: Partial<Record<EtapaFunil, EtapaFunil>> = {
|
||||||
|
lead: 'proposta',
|
||||||
|
proposta: 'negociacao',
|
||||||
|
negociacao: 'ganho',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANTERIOR: Partial<Record<EtapaFunil, EtapaFunil>> = {
|
||||||
|
proposta: 'lead',
|
||||||
|
negociacao: 'proposta',
|
||||||
|
ganho: 'negociacao',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(v: string | null | undefined): string {
|
||||||
|
if (!v) return '';
|
||||||
|
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function diasDesde(iso: string): number {
|
||||||
|
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nomePrincipal(op: Oportunidade): string {
|
||||||
|
return op.nomeCliente ?? op.nomeProspect ?? op.empresaProspect ?? `Oport. #${op.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OportCard({
|
||||||
|
op,
|
||||||
|
onEdit,
|
||||||
|
onMover,
|
||||||
|
onDeletar,
|
||||||
|
}: {
|
||||||
|
op: Oportunidade;
|
||||||
|
onEdit: () => void;
|
||||||
|
onMover: (etapa: EtapaFunil) => void;
|
||||||
|
onDeletar: () => void;
|
||||||
|
}) {
|
||||||
|
const dias = diasDesde(op.updatedAt);
|
||||||
|
const prox = PROXIMA[op.etapa as EtapaFunil];
|
||||||
|
const ant = ANTERIOR[op.etapa as EtapaFunil];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 10, borderRadius: 8, border: '1px solid #E2E8F0' }}
|
||||||
|
styles={{ body: { padding: '10px 12px' } }}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="flex-start" gap={8}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1e293b', display: 'block' }} ellipsis>
|
||||||
|
{nomePrincipal(op)}
|
||||||
|
</Text>
|
||||||
|
{op.titulo && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }} ellipsis>
|
||||||
|
{op.titulo}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Space size={2}>
|
||||||
|
<Tooltip title="Editar">
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Excluir">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Excluir oportunidade?',
|
||||||
|
content: 'Esta ação não pode ser desfeita.',
|
||||||
|
okText: 'Excluir',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: onDeletar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginTop: 6 }}>
|
||||||
|
<Text style={{ fontSize: 12, color: '#003B8E', fontWeight: 600 }}>
|
||||||
|
{op.valorEstimado ? fmt(op.valorEstimado) : '—'}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{dias === 0 ? 'hoje' : `${dias}d`}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex gap={4} style={{ marginTop: 8 }}>
|
||||||
|
{ant && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{ fontSize: 11, padding: '0 6px', color: '#64748b', borderColor: '#CBD5E1' }}
|
||||||
|
onClick={() => onMover(ant)}
|
||||||
|
>
|
||||||
|
← {ETAPA_CONFIG[ant].label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{op.etapa !== 'perdido' && op.etapa !== 'ganho' && (
|
||||||
|
<Tooltip title="Marcar como perdido">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
ghost
|
||||||
|
style={{ fontSize: 11, padding: '0 6px' }}
|
||||||
|
onClick={() => onMover('perdido')}
|
||||||
|
>
|
||||||
|
Perdido
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{prox && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '0 6px',
|
||||||
|
background: ETAPA_CONFIG[prox].cor,
|
||||||
|
borderColor: ETAPA_CONFIG[prox].cor,
|
||||||
|
}}
|
||||||
|
onClick={() => onMover(prox)}
|
||||||
|
>
|
||||||
|
{ETAPA_CONFIG[prox].label} <ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{op.etapa === 'ganho' && (
|
||||||
|
<Tag color="green" style={{ fontSize: 11, margin: 0 }}>
|
||||||
|
<TrophyOutlined /> Ganho
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal criar/editar ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
titulo: string;
|
||||||
|
etapa: EtapaFunil;
|
||||||
|
nomeProspect?: string;
|
||||||
|
empresaProspect?: string;
|
||||||
|
valorEstimado?: number;
|
||||||
|
observacoes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function OportModal({
|
||||||
|
open,
|
||||||
|
initial,
|
||||||
|
etapaInicial,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
initial?: Oportunidade | null;
|
||||||
|
etapaInicial: EtapaFunil;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (dto: CreateOportunidadeDto | UpdateOportunidadeDto) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
titulo: values.titulo,
|
||||||
|
etapa: values.etapa,
|
||||||
|
nomeProspect: values.nomeProspect ?? null,
|
||||||
|
empresaProspect: values.empresaProspect ?? null,
|
||||||
|
valorEstimado: values.valorEstimado ?? null,
|
||||||
|
observacoes: values.observacoes ?? null,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={initial ? 'Editar Oportunidade' : 'Nova Oportunidade'}
|
||||||
|
width={520}
|
||||||
|
centered
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={handleOk}
|
||||||
|
okText={initial ? 'Salvar' : 'Criar'}
|
||||||
|
confirmLoading={saving}
|
||||||
|
afterOpenChange={(vis) => {
|
||||||
|
if (vis) {
|
||||||
|
form.setFieldsValue(
|
||||||
|
initial
|
||||||
|
? {
|
||||||
|
titulo: initial.titulo,
|
||||||
|
etapa: initial.etapa as EtapaFunil,
|
||||||
|
nomeProspect: initial.nomeProspect ?? undefined,
|
||||||
|
empresaProspect: initial.empresaProspect ?? undefined,
|
||||||
|
valorEstimado: initial.valorEstimado ? Number(initial.valorEstimado) : undefined,
|
||||||
|
observacoes: initial.observacoes ?? undefined,
|
||||||
|
}
|
||||||
|
: { etapa: etapaInicial },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="titulo"
|
||||||
|
label="Título / produto de interesse"
|
||||||
|
rules={[{ required: true, message: 'Informe o título' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Ex: Reposição de estoque Q3" />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="nomeProspect" label="Nome do contato">
|
||||||
|
<Input placeholder="João Silva" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="empresaProspect" label="Empresa">
|
||||||
|
<Input placeholder="Empresa XYZ" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="valorEstimado" label="Valor estimado (R$)">
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
formatter={(v) => String(v).replace(/\B(?=(\d{3})+(?!\d))/g, '.')}
|
||||||
|
parser={(v) => Number((v ?? '').replace(/\./g, '').replace(',', '.'))}
|
||||||
|
placeholder="0,00"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="etapa" label="Estágio">
|
||||||
|
<Select
|
||||||
|
options={ETAPAS_FUNIL.map((e) => ({
|
||||||
|
value: e,
|
||||||
|
label: ETAPA_CONFIG[e].label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="observacoes" label="Observações">
|
||||||
|
<Input.TextArea rows={3} placeholder="Anotações sobre o lead..." />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Coluna kanban ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function KanbanColuna({
|
||||||
|
etapa,
|
||||||
|
ops,
|
||||||
|
onEdit,
|
||||||
|
onMover,
|
||||||
|
onDeletar,
|
||||||
|
onNovo,
|
||||||
|
}: {
|
||||||
|
etapa: EtapaFunil;
|
||||||
|
ops: Oportunidade[];
|
||||||
|
onEdit: (op: Oportunidade) => void;
|
||||||
|
onMover: (id: number, etapa: EtapaFunil) => void;
|
||||||
|
onDeletar: (id: number) => void;
|
||||||
|
onNovo: (etapa: EtapaFunil) => void;
|
||||||
|
}) {
|
||||||
|
const cfg = ETAPA_CONFIG[etapa];
|
||||||
|
const total = ops.reduce((s, o) => s + (o.valorEstimado ? Number(o.valorEstimado) : 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: 240,
|
||||||
|
flex: 1,
|
||||||
|
background: cfg.corBg,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${cfg.cor}33`,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px 8px',
|
||||||
|
borderBottom: `2px solid ${cfg.cor}`,
|
||||||
|
borderRadius: '10px 10px 0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Space size={6}>
|
||||||
|
<Text strong style={{ color: cfg.cor, fontSize: 13 }}>
|
||||||
|
{cfg.label}
|
||||||
|
</Text>
|
||||||
|
<Badge count={ops.length} color={cfg.cor} style={{ fontSize: 11 }} />
|
||||||
|
</Space>
|
||||||
|
{total > 0 && (
|
||||||
|
<Text style={{ fontSize: 11, color: cfg.cor, fontWeight: 600 }}>
|
||||||
|
{total.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 10px 4px',
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
maxHeight: 'calc(100vh - 260px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ops.map((op) => (
|
||||||
|
<OportCard
|
||||||
|
key={op.id}
|
||||||
|
op={op}
|
||||||
|
onEdit={() => onEdit(op)}
|
||||||
|
onMover={(e) => onMover(op.id, e)}
|
||||||
|
onDeletar={() => onDeletar(op.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '4px 10px 10px' }}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
style={{ borderColor: cfg.cor, color: cfg.cor, fontSize: 12 }}
|
||||||
|
onClick={() => onNovo(etapa)}
|
||||||
|
>
|
||||||
|
Adicionar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FunilPage ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function FunilPage() {
|
||||||
|
const { data, isLoading, isError, error } = useFunil();
|
||||||
|
const criar = useCreateOportunidade();
|
||||||
|
const atualizar = useUpdateOportunidade();
|
||||||
|
const deletar = useDeleteOportunidade();
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editOp, setEditOp] = useState<Oportunidade | null>(null);
|
||||||
|
const [etapaModal, setEtapaModal] = useState<EtapaFunil>('lead');
|
||||||
|
|
||||||
|
const abrirNovo = (etapa: EtapaFunil) => {
|
||||||
|
setEditOp(null);
|
||||||
|
setEtapaModal(etapa);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const abrirEditar = (op: Oportunidade) => {
|
||||||
|
setEditOp(op);
|
||||||
|
setEtapaModal(op.etapa as EtapaFunil);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (dto: CreateOportunidadeDto | UpdateOportunidadeDto) => {
|
||||||
|
if (editOp) {
|
||||||
|
await atualizar.mutateAsync({ id: editOp.id, dto });
|
||||||
|
} else {
|
||||||
|
await criar.mutateAsync(dto as CreateOportunidadeDto);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMover = (id: number, etapa: EtapaFunil) => {
|
||||||
|
atualizar.mutate({ id, dto: { etapa } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletar = (id: number) => {
|
||||||
|
deletar.mutate(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const etapasPrincipais: EtapaFunil[] = ['lead', 'proposta', 'negociacao', 'ganho'];
|
||||||
|
const perdidos = data?.perdido ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 'calc(100vh - var(--layout-topbar-height) - 48px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex align="center" justify="space-between" style={{ marginBottom: 16, flexShrink: 0 }}>
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
Funil de Vendas
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Acompanhe seus leads até o fechamento do pedido
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => abrirNovo('lead')}
|
||||||
|
style={{ background: '#003B8E' }}
|
||||||
|
>
|
||||||
|
Novo Lead
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Erro ao carregar funil"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
style={{ marginBottom: 16, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flex: 1, overflowX: 'auto', paddingBottom: 8 }}>
|
||||||
|
{etapasPrincipais.map((etapa) => (
|
||||||
|
<KanbanColuna
|
||||||
|
key={etapa}
|
||||||
|
etapa={etapa}
|
||||||
|
ops={data?.[etapa] ?? []}
|
||||||
|
onEdit={abrirEditar}
|
||||||
|
onMover={handleMover}
|
||||||
|
onDeletar={handleDeletar}
|
||||||
|
onNovo={abrirNovo}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{perdidos.length > 0 && (
|
||||||
|
<div style={{ marginTop: 12, flexShrink: 0 }}>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
size="small"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'perdidos',
|
||||||
|
label: (
|
||||||
|
<Text style={{ color: '#DC2626', fontSize: 13 }}>
|
||||||
|
Perdidos ({perdidos.length})
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<Row gutter={[10, 10]}>
|
||||||
|
{perdidos.map((op) => (
|
||||||
|
<Col key={op.id} xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<OportCard
|
||||||
|
op={op}
|
||||||
|
onEdit={() => abrirEditar(op)}
|
||||||
|
onMover={(e) => handleMover(op.id, e)}
|
||||||
|
onDeletar={() => handleDeletar(op.id)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OportModal
|
||||||
|
open={modalOpen}
|
||||||
|
initial={editOp}
|
||||||
|
etapaInicial={etapaModal}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
435
apps/web/src/cockpits/rep/NewClientPage.tsx
Normal file
435
apps/web/src/cockpits/rep/NewClientPage.tsx
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Row,
|
||||||
|
Segmented,
|
||||||
|
Select,
|
||||||
|
Spin,
|
||||||
|
Switch,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
BankOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
PhoneOutlined,
|
||||||
|
ShopOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import type { CreateClientNovo } from '@sar/api-interface';
|
||||||
|
import { useMunicipios, usePautas, useFormasPagamento } from '../../lib/queries/catalog';
|
||||||
|
import { useCreateClientNovo } from '../../lib/queries/clients';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
type Pessoa = 'PJ' | 'PF';
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottom: '1px solid #EBF0F5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#003B8E', fontSize: 16 }}>{icon}</span>
|
||||||
|
<Text strong style={{ fontSize: 14, color: '#003B8E' }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewClientPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [pessoa, setPessoa] = useState<Pessoa>('PJ');
|
||||||
|
const [municipioSearch, setMunicipioSearch] = useState('');
|
||||||
|
const [syncError, setSyncError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: municipios = [], isFetching: loadingMun } = useMunicipios(
|
||||||
|
municipioSearch || undefined,
|
||||||
|
);
|
||||||
|
const { data: pautas = [] } = usePautas();
|
||||||
|
const { data: formas = [] } = useFormasPagamento();
|
||||||
|
const createMutation = useCreateClientNovo();
|
||||||
|
|
||||||
|
async function handleCepBlur(cep: string) {
|
||||||
|
const digits = cep.replace(/\D/g, '');
|
||||||
|
if (digits.length !== 8) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://viacep.com.br/ws/${digits}/json/`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
erro?: boolean;
|
||||||
|
logradouro?: string;
|
||||||
|
bairro?: string;
|
||||||
|
localidade?: string;
|
||||||
|
uf?: string;
|
||||||
|
};
|
||||||
|
if (data.erro) return;
|
||||||
|
if (data.logradouro) form.setFieldValue('endereco', data.logradouro);
|
||||||
|
if (data.bairro) form.setFieldValue('bairro', data.bairro);
|
||||||
|
if (data.localidade && data.uf) {
|
||||||
|
setMunicipioSearch(data.localidade);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silencioso — CEP inválido não bloqueia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFinish(values: Record<string, unknown>) {
|
||||||
|
setSyncError(null);
|
||||||
|
|
||||||
|
const payload: CreateClientNovo = {
|
||||||
|
pesso: pessoa === 'PJ' ? 0 : 1,
|
||||||
|
consfinal: values.consfinal ? 1 : 0,
|
||||||
|
nome: String(values.nome ?? '').slice(0, 40),
|
||||||
|
razao: String(values.razao ?? values.nome ?? '').slice(0, 65),
|
||||||
|
cgcpf: values.cgcpf ? String(values.cgcpf) : undefined,
|
||||||
|
sufCgcpf: '',
|
||||||
|
inscr: values.inscr ? String(values.inscr) : '',
|
||||||
|
indicadorIe: values.indicadorIe as 1 | 2 | 9 | undefined,
|
||||||
|
endereco: String(values.endereco ?? '').slice(0, 60),
|
||||||
|
numEndereco: String(values.numEndereco ?? '').slice(0, 10),
|
||||||
|
bairro: String(values.bairro ?? '').slice(0, 60),
|
||||||
|
idMunicipio: Number(values.idMunicipio),
|
||||||
|
cep: String(values.cep ?? '')
|
||||||
|
.replace(/\D/g, '')
|
||||||
|
.slice(0, 9),
|
||||||
|
ddd: String(values.ddd ?? '').slice(0, 4),
|
||||||
|
telefone: String(values.telefone ?? '').slice(0, 35),
|
||||||
|
email: String(values.email ?? '').slice(0, 80),
|
||||||
|
limiteCredito: Number(values.limiteCredito ?? 0),
|
||||||
|
codFormapag: values.codFormapag ? Number(values.codFormapag) : undefined,
|
||||||
|
codPauta: values.codPauta ? Number(values.codPauta) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||||
|
try {
|
||||||
|
result = await createMutation.mutateAsync(payload);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.sincronizado && result.erroSync) {
|
||||||
|
setSyncError(result.erroSync);
|
||||||
|
void message.warning('Cliente cadastrado, mas houve erro na sincronização com o ERP.');
|
||||||
|
} else {
|
||||||
|
void message.success(
|
||||||
|
`Cliente cadastrado com sucesso! Código ERP: ${result.idCorrentErp ?? '—'}`,
|
||||||
|
);
|
||||||
|
void navigate({ to: '/clientes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPJ = pessoa === 'PJ';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 860, margin: '0 auto', padding: '24px 16px' }}>
|
||||||
|
{/* Cabeçalho */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 24 }}>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => void navigate({ to: '/clientes' })}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
Cadastrar Cliente
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Após salvar, o cliente é criado automaticamente no ERP.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seletor PJ / PF */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Segmented
|
||||||
|
size="large"
|
||||||
|
value={pessoa}
|
||||||
|
onChange={(v) => {
|
||||||
|
setPessoa(v as Pessoa);
|
||||||
|
form.resetFields(['cgcpf', 'inscr', 'indicadorIe', 'consfinal', 'razao']);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'PJ', icon: <BankOutlined />, label: 'CNPJ — Pessoa Jurídica' },
|
||||||
|
{ value: 'PF', icon: <UserOutlined />, label: 'CPF — Pessoa Física' },
|
||||||
|
]}
|
||||||
|
style={{ borderRadius: 10 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={(v) => void onFinish(v as Record<string, unknown>)}
|
||||||
|
requiredMark="optional"
|
||||||
|
initialValues={{ consfinal: false, limiteCredito: 0 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 12, border: '1px solid #EBF0F5', marginBottom: 16 }}
|
||||||
|
styles={{ body: { padding: '20px 24px' } }}
|
||||||
|
>
|
||||||
|
{/* ── Identificação ─────────────────────────────────────────── */}
|
||||||
|
<Section icon={isPJ ? <ShopOutlined /> : <UserOutlined />} title="Identificação">
|
||||||
|
<Row gutter={[16, 0]}>
|
||||||
|
{isPJ ? (
|
||||||
|
<>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="razao"
|
||||||
|
label="Razão Social"
|
||||||
|
rules={[{ required: true, message: 'Informe a razão social' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Razão Social Ltda" maxLength={65} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="nome"
|
||||||
|
label="Nome Fantasia"
|
||||||
|
rules={[{ required: true, message: 'Informe o nome fantasia' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Nome fantasia" maxLength={40} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="cgcpf" label="CNPJ">
|
||||||
|
<Input placeholder="00.000.000/0000-00" maxLength={18} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="inscr" label="Inscrição Estadual">
|
||||||
|
<Input placeholder="000.000.000" maxLength={18} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="indicadorIe" label="Indicador IE">
|
||||||
|
<Select placeholder="Selecione" allowClear>
|
||||||
|
<Select.Option value={1}>Contribuinte</Select.Option>
|
||||||
|
<Select.Option value={2}>Isento</Select.Option>
|
||||||
|
<Select.Option value={9}>Não contribuinte</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="consfinal" label="Consumidor Final" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Col xs={24} md={16}>
|
||||||
|
<Form.Item
|
||||||
|
name="nome"
|
||||||
|
label="Nome Completo"
|
||||||
|
rules={[{ required: true, message: 'Informe o nome' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Nome completo" maxLength={40} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="cgcpf" label="CPF">
|
||||||
|
<Input placeholder="000.000.000-00" maxLength={14} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Endereço ──────────────────────────────────────────────── */}
|
||||||
|
<Section icon={<EnvironmentOutlined />} title="Endereço">
|
||||||
|
<Row gutter={[16, 0]}>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Item name="cep" label="CEP">
|
||||||
|
<Input
|
||||||
|
placeholder="00000-000"
|
||||||
|
maxLength={9}
|
||||||
|
onBlur={(e) => void handleCepBlur(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={14}>
|
||||||
|
<Form.Item name="endereco" label="Endereço">
|
||||||
|
<Input placeholder="Rua / Av." maxLength={60} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={4}>
|
||||||
|
<Form.Item name="numEndereco" label="Número">
|
||||||
|
<Input placeholder="123" maxLength={10} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={10}>
|
||||||
|
<Form.Item name="bairro" label="Bairro">
|
||||||
|
<Input placeholder="Bairro" maxLength={60} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={14}>
|
||||||
|
<Form.Item
|
||||||
|
name="idMunicipio"
|
||||||
|
label="Município"
|
||||||
|
rules={[{ required: true, message: 'Selecione o município' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="Digite para buscar..."
|
||||||
|
filterOption={false}
|
||||||
|
onSearch={setMunicipioSearch}
|
||||||
|
notFoundContent={loadingMun ? <Spin size="small" /> : 'Nenhum resultado'}
|
||||||
|
optionLabelProp="label"
|
||||||
|
>
|
||||||
|
{municipios.map((m) => (
|
||||||
|
<Select.Option
|
||||||
|
key={m.idMunicipio}
|
||||||
|
value={m.idMunicipio}
|
||||||
|
label={`${m.nome} — ${m.uf}`}
|
||||||
|
>
|
||||||
|
{m.nome}{' '}
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{m.uf}
|
||||||
|
</Text>
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Contato ───────────────────────────────────────────────── */}
|
||||||
|
<Section icon={<PhoneOutlined />} title="Contato">
|
||||||
|
<Row gutter={[16, 0]}>
|
||||||
|
<Col xs={8} md={4}>
|
||||||
|
<Form.Item name="ddd" label="DDD">
|
||||||
|
<Input placeholder="48" maxLength={4} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={16} md={8}>
|
||||||
|
<Form.Item name="telefone" label="Telefone">
|
||||||
|
<Input placeholder="99999-9999" maxLength={35} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="email" label="E-mail">
|
||||||
|
<Input placeholder="contato@empresa.com.br" maxLength={80} type="email" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Comercial ─────────────────────────────────────────────── */}
|
||||||
|
<Section icon={<BankOutlined />} title="Comercial">
|
||||||
|
<Row gutter={[16, 0]}>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="codFormapag" label="Forma de Pagamento">
|
||||||
|
<Select placeholder="Selecione" allowClear>
|
||||||
|
{formas.map((f) => (
|
||||||
|
<Select.Option key={f.codigo} value={f.codigo}>
|
||||||
|
{f.descricao}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="codPauta" label="Tabela de Preços">
|
||||||
|
<Select placeholder="Selecione" allowClear>
|
||||||
|
{pautas.map((p) => (
|
||||||
|
<Select.Option key={p.idPauta} value={p.idPauta}>
|
||||||
|
{p.descricao}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="limiteCredito" label="Limite de Crédito">
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={0}
|
||||||
|
step={100}
|
||||||
|
formatter={(v) => `R$ ${v ?? 0}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')}
|
||||||
|
parser={(v) => Number((v ?? '').replace(/R\$\s?\.*/g, '').replace(',', '.'))}
|
||||||
|
placeholder="0,00"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{syncError && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="Falha na sincronização com o ERP"
|
||||||
|
description={syncError}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Rodapé sticky */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'sticky',
|
||||||
|
bottom: 0,
|
||||||
|
background: '#fff',
|
||||||
|
borderTop: '1px solid #EBF0F5',
|
||||||
|
padding: '12px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 12,
|
||||||
|
borderRadius: '0 0 12px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => void navigate({ to: '/clientes' })}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
style={{ borderRadius: 8, fontWeight: 600, minWidth: 160 }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Cadastrando...' : 'Cadastrar Cliente'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1764
apps/web/src/cockpits/rep/NewOrderPage.tsx
Normal file
1764
apps/web/src/cockpits/rep/NewOrderPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
540
apps/web/src/cockpits/rep/OrderDetailPage.tsx
Normal file
540
apps/web/src/cockpits/rep/OrderDetailPage.tsx
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Divider,
|
||||||
|
Form,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Timeline,
|
||||||
|
Typography,
|
||||||
|
Input,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FilePdfOutlined, CustomerServiceOutlined } from '@ant-design/icons';
|
||||||
|
import { AbrirChamadoModal } from './AbrirChamadoModal';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { Link, useParams, useNavigate } from '@tanstack/react-router';
|
||||||
|
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
|
||||||
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
|
import { useOrderDetail } from '../../lib/queries/orders';
|
||||||
|
import { useClientOrders } from '../../lib/queries/orders';
|
||||||
|
import { apiFetch } from '../../lib/api-client';
|
||||||
|
import { authStore } from '../../lib/auth-store';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SITUA_COLOR: Record<number, string> = {
|
||||||
|
0: 'default',
|
||||||
|
1: 'warning',
|
||||||
|
2: 'processing',
|
||||||
|
3: 'error',
|
||||||
|
4: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(v: string | number): string {
|
||||||
|
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShareText(order: {
|
||||||
|
numPedSar: string;
|
||||||
|
idCliente: number;
|
||||||
|
total: string;
|
||||||
|
itens: Array<{ descProduto: string | null; qtd: string; precoUnitario: string }>;
|
||||||
|
}): string {
|
||||||
|
const lines = [
|
||||||
|
`*Pedido ${order.numPedSar} — Cliente ${order.idCliente}*`,
|
||||||
|
'',
|
||||||
|
...order.itens.map(
|
||||||
|
(it) =>
|
||||||
|
`• ${it.descProduto ?? '?'} × ${Number(it.qtd).toLocaleString('pt-BR')} — ${fmt(it.precoUnitario)} un.`,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
`*Total: ${fmt(order.total)}*`,
|
||||||
|
];
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleFromToken(): string {
|
||||||
|
const token = authStore.get();
|
||||||
|
if (!token) return 'rep';
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1] ?? ''));
|
||||||
|
return (payload.role as string) ?? 'rep';
|
||||||
|
} catch {
|
||||||
|
return 'rep';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Subcomponents ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const itemColumns: TableColumnsType<PedidoItem> = [
|
||||||
|
{ title: 'Código', dataIndex: 'codProduto', width: 100 },
|
||||||
|
{ title: 'Produto', dataIndex: 'descProduto', ellipsis: true },
|
||||||
|
{ title: 'Qtd', dataIndex: 'qtd', width: 90, align: 'right' },
|
||||||
|
{
|
||||||
|
title: 'Preço Unit.',
|
||||||
|
dataIndex: 'precoUnitario',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: string) => fmt(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Desc %',
|
||||||
|
dataIndex: 'descontoPerc',
|
||||||
|
width: 80,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: string) => `${v}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
width: 130,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: string) => fmt(v),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
items={history.map((h) => ({
|
||||||
|
color:
|
||||||
|
SITUA_COLOR[h.situaNova] === 'success'
|
||||||
|
? 'green'
|
||||||
|
: SITUA_COLOR[h.situaNova] === 'warning'
|
||||||
|
? 'orange'
|
||||||
|
: SITUA_COLOR[h.situaNova] === 'error'
|
||||||
|
? 'red'
|
||||||
|
: 'blue',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
|
||||||
|
{h.situaAnterior != null && (
|
||||||
|
<Text type="secondary">
|
||||||
|
{' '}
|
||||||
|
(de {SITUA_LABEL[h.situaAnterior] ?? String(h.situaAnterior)})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<br />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{new Date(h.changedAt).toLocaleString('pt-BR')} — cod. {h.changedBy}
|
||||||
|
</Text>
|
||||||
|
{h.nota && (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Text italic>"{h.nota}"</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Approve Modal ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ApproveModal({
|
||||||
|
open,
|
||||||
|
originalDiscount,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
originalDiscount: string;
|
||||||
|
onConfirm: (descontoPerc?: number, nota?: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const [disc, setDisc] = useState<number | null>(null);
|
||||||
|
const [nota, setNota] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Aprovar Pedido"
|
||||||
|
open={open}
|
||||||
|
onOk={() => onConfirm(disc ?? undefined, nota || undefined)}
|
||||||
|
onCancel={onCancel}
|
||||||
|
okText="Confirmar Aprovação"
|
||||||
|
cancelText="Voltar"
|
||||||
|
confirmLoading={loading}
|
||||||
|
>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
label={`Desconto global (original: ${originalDiscount}%)`}
|
||||||
|
help="Deixe em branco para manter o desconto solicitado."
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.5}
|
||||||
|
placeholder={originalDiscount}
|
||||||
|
value={disc}
|
||||||
|
onChange={(v) => setDisc(v)}
|
||||||
|
addonAfter="%"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Observação (opcional)">
|
||||||
|
<TextArea
|
||||||
|
rows={2}
|
||||||
|
value={nota}
|
||||||
|
onChange={(e) => setNota(e.target.value)}
|
||||||
|
maxLength={300}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reject Modal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RejectModal({
|
||||||
|
open,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onConfirm: (motivo: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const [motivo, setMotivo] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Recusar Pedido"
|
||||||
|
open={open}
|
||||||
|
onOk={() => motivo.trim() && onConfirm(motivo.trim())}
|
||||||
|
onCancel={onCancel}
|
||||||
|
okText="Confirmar Recusa"
|
||||||
|
okButtonProps={{ danger: true, disabled: !motivo.trim() }}
|
||||||
|
cancelText="Voltar"
|
||||||
|
confirmLoading={loading}
|
||||||
|
>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="Motivo da recusa" required>
|
||||||
|
<TextArea
|
||||||
|
rows={3}
|
||||||
|
value={motivo}
|
||||||
|
onChange={(e) => setMotivo(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
showCount
|
||||||
|
placeholder="Informe o motivo para o representante..."
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OrderDetailPage ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function OrderDetailPage() {
|
||||||
|
const { id } = useParams({ from: '/pedidos/$id' });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data: order, isLoading, error } = useOrderDetail(id);
|
||||||
|
const { data: clientOrders } = useClientOrders(order?.idCliente);
|
||||||
|
|
||||||
|
const role = getRoleFromToken();
|
||||||
|
const isErp = order?.fonte === 'erp';
|
||||||
|
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
|
||||||
|
const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
|
||||||
|
const canShare =
|
||||||
|
role === 'rep' &&
|
||||||
|
(order?.situa === 2 || order?.situa === 4) &&
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
!!navigator.share;
|
||||||
|
|
||||||
|
const [approveOpen, setApproveOpen] = useState(false);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [chamadoOpen, setChamadoOpen] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: ({ descontoPerc, nota }: { descontoPerc?: number; nota?: string }) =>
|
||||||
|
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { descontoPerc, nota } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setApproveOpen(false);
|
||||||
|
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => {
|
||||||
|
setApproveOpen(false);
|
||||||
|
setActionError(e instanceof Error ? e.message : 'Erro ao aprovar');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectMutation = useMutation({
|
||||||
|
mutationFn: (motivo: string) =>
|
||||||
|
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { motivo } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setRejectOpen(false);
|
||||||
|
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => {
|
||||||
|
setRejectOpen(false);
|
||||||
|
setActionError(e instanceof Error ? e.message : 'Erro ao recusar');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const transmitMutation = useMutation({
|
||||||
|
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
|
},
|
||||||
|
// Mensagem de bloqueio de alçada (desconto acima do máximo) vem aqui.
|
||||||
|
onError: (e: unknown) => setActionError(e instanceof Error ? e.message : 'Erro ao transmitir'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||||
|
if (error || !order)
|
||||||
|
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
||||||
|
|
||||||
|
const timeWaiting =
|
||||||
|
order.situa === 1
|
||||||
|
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Orçamento: tela mais larga para consulta/revisão com o cliente.
|
||||||
|
const isOrcamento = order.situa === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, maxWidth: isOrcamento ? 1320 : 960, margin: '0 auto' }}>
|
||||||
|
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Link to="/pedidos">← Pedidos</Link>
|
||||||
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
|
{order.numPedSar}
|
||||||
|
</Title>
|
||||||
|
<Badge
|
||||||
|
status={
|
||||||
|
(SITUA_COLOR[order.situa] ?? 'default') as
|
||||||
|
| 'default'
|
||||||
|
| 'warning'
|
||||||
|
| 'processing'
|
||||||
|
| 'success'
|
||||||
|
| 'error'
|
||||||
|
}
|
||||||
|
text={
|
||||||
|
<Tag color={SITUA_COLOR[order.situa] ?? 'default'}>
|
||||||
|
{SITUA_LABEL[order.situa] ?? String(order.situa)}
|
||||||
|
</Tag>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{timeWaiting !== null && timeWaiting > 2 && (
|
||||||
|
<Tag color="red">Urgente — {timeWaiting}h aguardando</Tag>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
icon={<FilePdfOutlined />}
|
||||||
|
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
|
||||||
|
>
|
||||||
|
Gerar PDF
|
||||||
|
</Button>
|
||||||
|
{canTransmit && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={transmitMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setActionError(null);
|
||||||
|
transmitMutation.mutate();
|
||||||
|
}}
|
||||||
|
style={{ backgroundColor: '#389e0d', borderColor: '#389e0d' }}
|
||||||
|
>
|
||||||
|
Transmitir pedido
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canAct && (
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={() => setApproveOpen(true)}>
|
||||||
|
Aprovar
|
||||||
|
</Button>
|
||||||
|
<Button danger onClick={() => setRejectOpen(true)}>
|
||||||
|
Recusar
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<Button
|
||||||
|
icon={<FontAwesomeIcon icon={faShareNodes} />}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.share({ text: buildShareText(order) });
|
||||||
|
} catch {
|
||||||
|
void message.info('Compartilhamento cancelado');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Compartilhar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{role === 'rep' && !isErp && (order.situa === 2 || order.situa === 4) && (
|
||||||
|
<Button icon={<CustomerServiceOutlined />} onClick={() => setChamadoOpen(true)}>
|
||||||
|
Abrir Chamado
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{actionError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={actionError}
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={() => setActionError(null)}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Descriptions
|
||||||
|
bordered
|
||||||
|
size={isOrcamento ? 'middle' : 'small'}
|
||||||
|
column={isOrcamento ? 3 : 2}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="Cliente">
|
||||||
|
<Link to="/clientes/$id" params={{ id: String(order.idCliente) }}>
|
||||||
|
{order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`}
|
||||||
|
</Link>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Representante">
|
||||||
|
{order.nomeVendedor ?? `Cód. ${order.codVendedor}`}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Data">
|
||||||
|
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
{order.aprovadoEm && (
|
||||||
|
<Descriptions.Item label="Aprovado em">
|
||||||
|
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} — cód. {order.aprovadoPor}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{order.formaPagamento && (
|
||||||
|
<Descriptions.Item label="Cond. Pagamento" span={2}>
|
||||||
|
{order.formaPagamento}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Total" span={isOrcamento ? 1 : 2}>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
|
{fmt(order.total)}
|
||||||
|
</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{order.endEntrega && (
|
||||||
|
<Descriptions.Item label="End. Entrega" span={isOrcamento ? 3 : 2}>
|
||||||
|
{order.endEntrega}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{order.obs && (
|
||||||
|
<Descriptions.Item label="Observações" span={isOrcamento ? 3 : 2}>
|
||||||
|
{order.obs}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{order.motivoRecusa && (
|
||||||
|
<Descriptions.Item label="Motivo Recusa" span={isOrcamento ? 3 : 2}>
|
||||||
|
<Text type="danger">{order.motivoRecusa}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Divider titlePlacement="left">Itens ({order.itens.length})</Divider>
|
||||||
|
<Table<PedidoItem>
|
||||||
|
rowKey="id"
|
||||||
|
columns={itemColumns}
|
||||||
|
dataSource={order.itens}
|
||||||
|
pagination={false}
|
||||||
|
size={isOrcamento ? 'middle' : 'small'}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{clientOrders && clientOrders.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider titlePlacement="left">Outros Pedidos do Cliente</Divider>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={clientOrders.filter((o) => o.id !== id).slice(0, 5)}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'Nº',
|
||||||
|
dataIndex: 'numPedSar',
|
||||||
|
width: 110,
|
||||||
|
render: (n: string, r: { id: string }) => (
|
||||||
|
<Link to="/pedidos/$id" params={{ id: r.id }}>
|
||||||
|
{n}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'situa',
|
||||||
|
width: 130,
|
||||||
|
render: (s: number) => (
|
||||||
|
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => fmt(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Data',
|
||||||
|
dataIndex: 'dtPedido',
|
||||||
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider titlePlacement="left">Histórico do Pedido</Divider>
|
||||||
|
<HistoryTimeline history={order.historico} />
|
||||||
|
|
||||||
|
<ApproveModal
|
||||||
|
open={approveOpen}
|
||||||
|
originalDiscount={order.descontoPerc}
|
||||||
|
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })}
|
||||||
|
onCancel={() => setApproveOpen(false)}
|
||||||
|
loading={approveMutation.isPending}
|
||||||
|
/>
|
||||||
|
<RejectModal
|
||||||
|
open={rejectOpen}
|
||||||
|
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
|
||||||
|
onCancel={() => setRejectOpen(false)}
|
||||||
|
loading={rejectMutation.isPending}
|
||||||
|
/>
|
||||||
|
{order && (
|
||||||
|
<AbrirChamadoModal
|
||||||
|
open={chamadoOpen}
|
||||||
|
onClose={() => setChamadoOpen(false)}
|
||||||
|
idPedido={order.id}
|
||||||
|
numPedSar={parseInt(order.numPedSar.replace(/\D/g, ''), 10) || undefined}
|
||||||
|
nomeCliente={order.razaoCliente ?? order.nomeCliente}
|
||||||
|
idCliente={order.idCliente}
|
||||||
|
itens={order.itens}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
469
apps/web/src/cockpits/rep/OrderPrintPage.tsx
Normal file
469
apps/web/src/cockpits/rep/OrderPrintPage.tsx
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Button, Spin, Alert } from 'antd';
|
||||||
|
import { PrinterOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { useParams, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
|
import { useOrderDetail } from '../../lib/queries/orders';
|
||||||
|
import { useClientDetail } from '../../lib/queries/clients';
|
||||||
|
import { useCompany } from '../../lib/queries/company';
|
||||||
|
|
||||||
|
// ─── Paleta / tokens ────────────────────────────────────────────────────────
|
||||||
|
const BLUE = '#003B8E';
|
||||||
|
const INK = '#1F2937';
|
||||||
|
const MUTED = '#64748B';
|
||||||
|
const LINE = '#E5EAF0';
|
||||||
|
|
||||||
|
// ─── Helpers de formatação ──────────────────────────────────────────────────
|
||||||
|
function money(v: string | number | null | undefined): string {
|
||||||
|
const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0);
|
||||||
|
return (isNaN(n as number) ? 0 : (n as number)).toLocaleString('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function qty(v: string | number): string {
|
||||||
|
return Number(v).toLocaleString('pt-BR', { maximumFractionDigits: 3 });
|
||||||
|
}
|
||||||
|
function dateBR(v: string | null | undefined): string {
|
||||||
|
return v ? new Date(v).toLocaleDateString('pt-BR') : '—';
|
||||||
|
}
|
||||||
|
function doc(raw: string | null | undefined): string {
|
||||||
|
if (!raw) return '—';
|
||||||
|
const d = raw.replace(/\D/g, '');
|
||||||
|
if (d.length === 14) return d.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, '$1.$2.$3/$4-$5');
|
||||||
|
if (d.length === 11) return d.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
function phone(raw: string | null | undefined, ddd?: string | null): string {
|
||||||
|
const d = `${ddd ?? ''}${raw ?? ''}`.replace(/\D/g, '');
|
||||||
|
if (d.length === 11) return d.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
|
||||||
|
if (d.length === 10) return d.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3');
|
||||||
|
return raw ?? '—';
|
||||||
|
}
|
||||||
|
function cep(raw: string | null | undefined): string {
|
||||||
|
const d = (raw ?? '').replace(/\D/g, '');
|
||||||
|
return d.length === 8 ? d.replace(/(\d{5})(\d{3})/, '$1-$2') : (raw ?? '');
|
||||||
|
}
|
||||||
|
// Campos char do ERP vêm com padding — limpa, devolve null se vazio.
|
||||||
|
function tx(s: string | null | undefined): string | null {
|
||||||
|
const t = (s ?? '').trim();
|
||||||
|
return t === '' ? null : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Blocos visuais ─────────────────────────────────────────────────────────
|
||||||
|
const label: React.CSSProperties = {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: MUTED,
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrderPrintPage() {
|
||||||
|
const { id } = useParams({ from: '/pedidos/$id/imprimir' });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: order, isLoading, error } = useOrderDetail(id);
|
||||||
|
const { data: client } = useClientDetail(order?.idCliente);
|
||||||
|
const { data: empresa } = useCompany();
|
||||||
|
|
||||||
|
// Auto-abre o diálogo de impressão quando tudo carregou.
|
||||||
|
useEffect(() => {
|
||||||
|
if (order && empresa) {
|
||||||
|
const t = setTimeout(() => window.print(), 600);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [order, empresa]);
|
||||||
|
|
||||||
|
if (isLoading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
||||||
|
if (error || !order)
|
||||||
|
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
||||||
|
|
||||||
|
const enderecoCli = client
|
||||||
|
? [
|
||||||
|
tx(client.endereco),
|
||||||
|
tx(client.numEndereco),
|
||||||
|
tx(client.bairro),
|
||||||
|
client.cep ? `CEP ${cep(client.cep)}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
: null;
|
||||||
|
const cgDigits = (client?.cgcpf ?? '').replace(/\D/g, '').length;
|
||||||
|
const docLabel = cgDigits === 11 ? 'CPF' : 'CNPJ';
|
||||||
|
const enderecoEmp = empresa
|
||||||
|
? [empresa.endereco, empresa.numero, empresa.complemento, empresa.bairro]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
: null;
|
||||||
|
const cidadeEmp = empresa
|
||||||
|
? [empresa.cidade, empresa.uf].filter(Boolean).join(' - ') +
|
||||||
|
(empresa.cep ? ` · CEP ${cep(empresa.cep)}` : '')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const clienteNome =
|
||||||
|
tx(order.razaoCliente) ?? tx(order.nomeCliente) ?? `Cliente ${order.idCliente}`;
|
||||||
|
const temDesc = Number(order.descontoValor) > 0 || Number(order.descontoPerc) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#EEF2F7', minHeight: '100vh', padding: '24px 0 60px' }}>
|
||||||
|
{/* Barra de ações (não imprime) */}
|
||||||
|
<div
|
||||||
|
className="no-print"
|
||||||
|
style={{
|
||||||
|
maxWidth: 820,
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate({ to: '/pedidos/$id', params: { id } })}
|
||||||
|
>
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<PrinterOutlined />} onClick={() => window.print()}>
|
||||||
|
Imprimir / Salvar PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documento A4 */}
|
||||||
|
<div
|
||||||
|
className="sar-print"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 820,
|
||||||
|
margin: '0 auto',
|
||||||
|
background: '#fff',
|
||||||
|
boxShadow: '0 4px 24px rgba(0,0,0,0.10)',
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontFamily: "'Plus Jakarta Sans Variable', system-ui, sans-serif",
|
||||||
|
color: INK,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: '22px 28px',
|
||||||
|
borderTop: `5px solid ${BLUE}`,
|
||||||
|
background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)',
|
||||||
|
borderBottom: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: 460 }}>
|
||||||
|
<div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}>
|
||||||
|
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div>
|
||||||
|
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}>
|
||||||
|
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
|
||||||
|
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
|
||||||
|
{enderecoEmp && <div>{enderecoEmp}</div>}
|
||||||
|
{cidadeEmp && <div>{cidadeEmp}</div>}
|
||||||
|
{(empresa?.telefone || empresa?.email) && (
|
||||||
|
<div>
|
||||||
|
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
|
||||||
|
{empresa?.telefone && empresa?.email && <> · </>}
|
||||||
|
{empresa?.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: 10, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
|
||||||
|
PEDIDO
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
|
||||||
|
{order.numPedSar}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
marginTop: 6,
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: 20,
|
||||||
|
background: `${BLUE}12`,
|
||||||
|
color: BLUE,
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SITUA_LABEL[order.situa] ?? String(order.situa)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}>
|
||||||
|
Emissão: {dateBR(order.dtPedido)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Representante + Cliente ─────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
borderBottom: `1px solid ${LINE}`,
|
||||||
|
background: '#FAFBFD',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Representante */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px 20px 10px 28px',
|
||||||
|
borderRight: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 8.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: BLUE,
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Representante
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: INK, lineHeight: 1.3 }}>
|
||||||
|
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: MUTED, marginTop: 2 }}>Cód. {order.codVendedor}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cliente */}
|
||||||
|
<div style={{ flex: 2.2, padding: '10px 28px 10px 20px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 8.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: BLUE,
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cliente
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: INK, lineHeight: 1.3 }}>
|
||||||
|
{clienteNome}
|
||||||
|
</div>
|
||||||
|
{tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && (
|
||||||
|
<div style={{ fontSize: 10, color: MUTED, lineHeight: 1.3 }}>{tx(client?.nome)}</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '2px 18px',
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 10,
|
||||||
|
color: MUTED,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{doc(client?.cgcpf) !== '—' && (
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: INK }}>{docLabel}:</strong> {doc(client?.cgcpf)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tx(client?.inscricaoEstadual) && (
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: INK }}>IE:</strong> {tx(client?.inscricaoEstadual)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{enderecoCli && <span>{enderecoCli}</span>}
|
||||||
|
{phone(client?.telefone, client?.ddd) !== '—' && (
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: INK }}>Tel:</strong>{' '}
|
||||||
|
{phone(client?.telefone, client?.ddd)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tx(client?.email) && <span>{tx(client?.email)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Itens ───────────────────────────────────────────────────────── */}
|
||||||
|
<div style={{ padding: '0 28px 0' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#F4F7FB' }}>
|
||||||
|
{['Cód.', 'Produto', 'Qtd', 'Preço un.', 'Desc.', 'Total'].map((h, i) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
style={{
|
||||||
|
textAlign: i >= 2 ? 'right' : 'left',
|
||||||
|
padding: '8px 8px',
|
||||||
|
color: MUTED,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 9.5,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
borderBottom: `2px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{order.itens.map((it, idx) => (
|
||||||
|
<tr key={it.id} style={{ background: idx % 2 ? '#FBFCFE' : '#fff' }}>
|
||||||
|
<td
|
||||||
|
style={{ padding: '7px 8px', color: MUTED, borderBottom: `1px solid ${LINE}` }}
|
||||||
|
>
|
||||||
|
{it.codProduto ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', color: INK, borderBottom: `1px solid ${LINE}` }}>
|
||||||
|
{it.descProduto ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '7px 8px',
|
||||||
|
textAlign: 'right',
|
||||||
|
borderBottom: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{qty(it.qtd)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '7px 8px',
|
||||||
|
textAlign: 'right',
|
||||||
|
borderBottom: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{money(it.precoUnitario)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '7px 8px',
|
||||||
|
textAlign: 'right',
|
||||||
|
color: MUTED,
|
||||||
|
borderBottom: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Number(it.descontoPerc) > 0 ? `${Number(it.descontoPerc)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '7px 8px',
|
||||||
|
textAlign: 'right',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: INK,
|
||||||
|
borderBottom: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{money(it.total)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Totais ──────────────────────────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '14px 28px 4px' }}>
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<TotRow k="Total dos produtos" v={money(order.totalProdutos)} />
|
||||||
|
{Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />}
|
||||||
|
{Number(order.totalIcmsst) > 0 && <TotRow k="ICMS-ST" v={money(order.totalIcmsst)} />}
|
||||||
|
{temDesc && (
|
||||||
|
<TotRow
|
||||||
|
k={`Desconto${Number(order.descontoPerc) > 0 ? ` (${Number(order.descontoPerc)}%)` : ''}`}
|
||||||
|
v={`- ${money(order.descontoValor)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: BLUE,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.04em' }}>TOTAL</span>
|
||||||
|
<span style={{ fontSize: 17, fontWeight: 800 }}>{money(order.total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Endereço de entrega + Observações ───────────────────────────── */}
|
||||||
|
{(order.endEntrega || order.obs) && (
|
||||||
|
<div style={{ padding: '10px 28px 0', display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||||
|
{order.endEntrega && (
|
||||||
|
<div style={{ flex: 1, minWidth: 220 }}>
|
||||||
|
<span style={label}>Endereço de entrega</span>
|
||||||
|
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>
|
||||||
|
{order.endEntrega}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{order.obs && (
|
||||||
|
<div style={{ flex: 1, minWidth: 220 }}>
|
||||||
|
<span style={label}>Observações</span>
|
||||||
|
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: '18px 28px 0',
|
||||||
|
padding: '12px 0 18px',
|
||||||
|
borderTop: `1px solid ${LINE}`,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: 9.5,
|
||||||
|
color: '#94A3B8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Documento sem valor fiscal · Pedido de venda emitido pelo representante via SAR.
|
||||||
|
</span>
|
||||||
|
<span>SAR · Powered by JCS Sistemas</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CSS de impressão: esconde tudo menos o documento */}
|
||||||
|
<style>{`
|
||||||
|
@media print {
|
||||||
|
@page { size: A4; margin: 10mm; }
|
||||||
|
body * { visibility: hidden !important; }
|
||||||
|
.sar-print, .sar-print * { visibility: visible !important; }
|
||||||
|
.sar-print { position: absolute; left: 0; top: 0; width: 100% !important;
|
||||||
|
max-width: none !important; box-shadow: none !important; border-radius: 0 !important; }
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotRow({ k, v }: { k: string; v: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '4px 14px',
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: MUTED,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{k}</span>
|
||||||
|
<span style={{ color: INK, fontWeight: 600 }}>{v}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
639
apps/web/src/cockpits/rep/OrdersErpPage.tsx
Normal file
639
apps/web/src/cockpits/rep/OrdersErpPage.tsx
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Modal,
|
||||||
|
Input,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import {
|
||||||
|
CopyOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ClearOutlined,
|
||||||
|
ShoppingCartOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { PedidoErpConsultaItem } from '@sar/api-interface';
|
||||||
|
import { useOrderErpConsulta, useOrderDetail } from '../../lib/queries/orders';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
function fmt(v: number | string) {
|
||||||
|
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(v: string | null | undefined) {
|
||||||
|
if (!v) return '—';
|
||||||
|
return new Date(v).toLocaleDateString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status para registros tipo='E' (sig.pedidos entregas)
|
||||||
|
const SIG_STATUS_ENTREGA: Record<number, { label: string; color: string }> = {
|
||||||
|
1: { label: 'Pendente', color: 'orange' },
|
||||||
|
2: { label: 'Liberado', color: 'green' },
|
||||||
|
3: { label: 'Emitido', color: 'geekblue' },
|
||||||
|
5: { label: 'Cancelado', color: 'red' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusTag(row: PedidoErpConsultaItem) {
|
||||||
|
const cfg = row.tipo === 'E' ? SIG_STATUS_ENTREGA[row.situa] : undefined;
|
||||||
|
const label = row.statusDescr ?? cfg?.label ?? String(row.situa);
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
color={cfg?.color ?? 'default'}
|
||||||
|
style={{ borderRadius: 20, fontWeight: 600, fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal de detalhes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ErpConsultaModal({
|
||||||
|
row,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
row: PedidoErpConsultaItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { data: detail, isLoading } = useOrderDetail(
|
||||||
|
row?.idPedido ? `erp-${row.idPedido}` : undefined,
|
||||||
|
);
|
||||||
|
const { message: msg } = App.useApp();
|
||||||
|
|
||||||
|
const label: React.CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: '#94A3B8',
|
||||||
|
marginBottom: 2,
|
||||||
|
display: 'block',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
row ? (
|
||||||
|
<Space size={8}>
|
||||||
|
<Text strong style={{ color: '#003B8E' }}>
|
||||||
|
Pedido {row.numeroPedido}
|
||||||
|
</Text>
|
||||||
|
<Tag
|
||||||
|
color={row.tipo === 'P' ? 'blue' : 'cyan'}
|
||||||
|
style={{ fontWeight: 700, borderRadius: 20, margin: 0 }}
|
||||||
|
>
|
||||||
|
{row.tipo === 'P' ? 'Pedido' : 'Entrega'}
|
||||||
|
</Tag>
|
||||||
|
{row && statusTag(row)}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
'Detalhes'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
open={!!row}
|
||||||
|
onCancel={onClose}
|
||||||
|
centered
|
||||||
|
width={860}
|
||||||
|
destroyOnHidden
|
||||||
|
styles={{
|
||||||
|
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
|
||||||
|
}}
|
||||||
|
footer={<Button onClick={onClose}>Fechar</Button>}
|
||||||
|
>
|
||||||
|
{!row ? null : (
|
||||||
|
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||||
|
{/* ── Cabeçalho ── */}
|
||||||
|
<Card
|
||||||
|
styles={{ body: { padding: '14px 16px' } }}
|
||||||
|
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 10]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<span style={label}>Cliente</span>
|
||||||
|
<Text strong style={{ display: 'block' }}>
|
||||||
|
{row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`}
|
||||||
|
</Text>
|
||||||
|
{row.nomeCliente && row.razaoCliente && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{row.nomeCliente}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Data</span>
|
||||||
|
<Text>{fmtDate(row.data)}</Text>
|
||||||
|
</Col>
|
||||||
|
{row.dtPrevent && (
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Prev. Entrega</span>
|
||||||
|
<Text>{fmtDate(row.dtPrevent)}</Text>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Forma Pagto.</span>
|
||||||
|
<Text>{row.formaPagamento ?? '—'}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Total</span>
|
||||||
|
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
|
||||||
|
{fmt(row.total)}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
{row.numPedSar && (
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Nº SAR</span>
|
||||||
|
<Text style={{ color: '#64748B' }}>{row.numPedSar}</Text>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{row.obs && (
|
||||||
|
<Col span={24}>
|
||||||
|
<span style={label}>Observações</span>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{row.obs}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Dados de Entrega / NF (só tipo E) ── */}
|
||||||
|
{row.tipo === 'E' && (
|
||||||
|
<Card
|
||||||
|
styles={{ body: { padding: '14px 16px' } }}
|
||||||
|
style={{ borderRadius: 8, border: '1px solid #d1fae5', background: '#f0fdf4' }}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 10]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Nº Entrega</span>
|
||||||
|
<Text strong className="tabular-nums">
|
||||||
|
{row.numeroEntrega ?? '—'}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
{row.dataEmissao && (
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Data Emissão</span>
|
||||||
|
<Text>{fmtDate(row.dataEmissao)}</Text>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{row.numeroNf && (
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>NF</span>
|
||||||
|
<Space size={4}>
|
||||||
|
<FileTextOutlined style={{ color: '#003B8E' }} />
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{row.numeroNf}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{row.chaveAcessoNfe && (
|
||||||
|
<Col span={24}>
|
||||||
|
<span style={label}>Chave NF-e</span>
|
||||||
|
<Space size={6} align="start">
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#475569',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.chaveAcessoNfe}
|
||||||
|
</Text>
|
||||||
|
<Tooltip title="Copiar chave">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(row.chaveAcessoNfe ?? '')
|
||||||
|
.then(() => void msg.success('Chave copiada'))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Itens do Pedido ── */}
|
||||||
|
<div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 32 }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : !detail?.itens?.length ? (
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Nenhum item encontrado para este registro.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={detail.itens}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
style={{ borderRadius: 8, border: '1px solid #EBF0F5' }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'Produto',
|
||||||
|
key: 'produto',
|
||||||
|
render: (_: unknown, item) => (
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
|
||||||
|
<Text style={{ fontWeight: 500, fontSize: 13 }}>{item.descProduto}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Qtd.',
|
||||||
|
dataIndex: 'qtd',
|
||||||
|
width: 70,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => <Text className="tabular-nums">{Number(v)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Preço Un.',
|
||||||
|
dataIndex: 'precoUnitario',
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string, item) => (
|
||||||
|
<Space orientation="vertical" size={0} style={{ alignItems: 'flex-end' }}>
|
||||||
|
<Text className="tabular-nums">{fmt(Number(v))}</Text>
|
||||||
|
{Number(item.descontoPerc) > 0 && (
|
||||||
|
<Text style={{ fontSize: 11, color: '#f59e0b' }}>
|
||||||
|
-{Number(item.descontoPerc)}%
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(Number(v))}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
summary={() => (
|
||||||
|
<Table.Summary.Row>
|
||||||
|
<Table.Summary.Cell index={0} colSpan={3} align="right">
|
||||||
|
<Text strong>Total</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={1} align="right">
|
||||||
|
<Text strong style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(detail.total)}
|
||||||
|
</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
</Table.Summary.Row>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OrdersErpPage ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function OrdersErpPage() {
|
||||||
|
const { message: msg } = App.useApp();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [drawerRow, setDrawerRow] = useState<PedidoErpConsultaItem | null>(null);
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
const from = range ? range[0].format('YYYY-MM-DD') : undefined;
|
||||||
|
const to = range ? range[1].format('YYYY-MM-DD') : undefined;
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useOrderErpConsulta({
|
||||||
|
search: query || undefined,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const hasFilters = !!query || !!range;
|
||||||
|
|
||||||
|
function commitSearch() {
|
||||||
|
setQuery(search.trim());
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setSearch('');
|
||||||
|
setQuery('');
|
||||||
|
setRange(null);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<PedidoErpConsultaItem> = [
|
||||||
|
{
|
||||||
|
title: 'Pedido',
|
||||||
|
key: 'pedido',
|
||||||
|
width: 110,
|
||||||
|
render: (_: unknown, row: PedidoErpConsultaItem) => (
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{row.numeroPedido}
|
||||||
|
</Text>
|
||||||
|
{row.numPedSar && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{row.numPedSar}</Text>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tipo',
|
||||||
|
dataIndex: 'tipo',
|
||||||
|
key: 'tipo',
|
||||||
|
width: 90,
|
||||||
|
render: (tipo: 'P' | 'E') => (
|
||||||
|
<Tag color={tipo === 'P' ? 'blue' : 'cyan'} style={{ fontWeight: 700, borderRadius: 20 }}>
|
||||||
|
{tipo === 'P' ? 'Pedido' : 'Entrega'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Data',
|
||||||
|
dataIndex: 'data',
|
||||||
|
key: 'data',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => <Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
key: 'cliente',
|
||||||
|
minWidth: 220,
|
||||||
|
render: (_: unknown, row: PedidoErpConsultaItem) => {
|
||||||
|
const nome = row.razaoCliente ?? row.nomeCliente;
|
||||||
|
const sub = row.nomeCliente && row.razaoCliente ? row.nomeCliente : null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{nome ? (
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1F2937', display: 'block' }}>
|
||||||
|
{nome}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">Cód. {row.idCliente}</Text>
|
||||||
|
)}
|
||||||
|
{sub && <Text style={{ fontSize: 11, color: '#64748B', display: 'block' }}>{sub}</Text>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'situa',
|
||||||
|
key: 'status',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, row: PedidoErpConsultaItem) => statusTag(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
key: 'total',
|
||||||
|
width: 130,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E', fontSize: 13 }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Forma Pagto.',
|
||||||
|
dataIndex: 'formaPagamento',
|
||||||
|
key: 'formaPagamento',
|
||||||
|
width: 140,
|
||||||
|
render: (v: string | null) => (
|
||||||
|
<Text style={{ fontSize: 12, color: '#475569' }}>{v ?? '—'}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Entrega / NF',
|
||||||
|
key: 'entrega',
|
||||||
|
width: 140,
|
||||||
|
render: (_: unknown, row: PedidoErpConsultaItem) => {
|
||||||
|
if (row.tipo !== 'E') return <Text type="secondary">—</Text>;
|
||||||
|
return (
|
||||||
|
<Space orientation="vertical" size={2}>
|
||||||
|
{row.numeroEntrega && (
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 12, color: '#475569' }}>
|
||||||
|
Entrega {row.numeroEntrega}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{row.numeroNf && (
|
||||||
|
<Space size={4}>
|
||||||
|
<FileTextOutlined style={{ color: '#003B8E', fontSize: 11 }} />
|
||||||
|
<Text strong className="tabular-nums" style={{ fontSize: 12, color: '#003B8E' }}>
|
||||||
|
NF {row.numeroNf}
|
||||||
|
</Text>
|
||||||
|
{row.chaveAcessoNfe && (
|
||||||
|
<Tooltip title={row.chaveAcessoNfe}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined style={{ fontSize: 10 }} />}
|
||||||
|
style={{ padding: '0 2px', height: 18, color: '#94A3B8' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(row.chaveAcessoNfe ?? '')
|
||||||
|
.then(() => void msg.success('Chave copiada'));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 56,
|
||||||
|
render: (_: unknown, row: PedidoErpConsultaItem) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
style={{ borderRadius: 6 }}
|
||||||
|
disabled={!row.idPedido}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDrawerRow(row);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
Consulta de Pedidos ERP
|
||||||
|
</Title>
|
||||||
|
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
|
||||||
|
Pedidos e entregas no ERP. Apenas leitura.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid #EBF0F5',
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '14px 20px' } }}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 12]} align="middle">
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onPressEnter={commitSearch}
|
||||||
|
onBlur={commitSearch}
|
||||||
|
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
|
||||||
|
placeholder="Nº pedido ou cliente..."
|
||||||
|
allowClear
|
||||||
|
onClear={() => {
|
||||||
|
setSearch('');
|
||||||
|
setQuery('');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<RangePicker
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={range}
|
||||||
|
format="DD/MM/YYYY"
|
||||||
|
allowClear
|
||||||
|
placeholder={['Data inicial', 'Data final']}
|
||||||
|
onChange={(dates) => {
|
||||||
|
if (dates && dates[0] && dates[1]) {
|
||||||
|
setRange([dates[0], dates[1]]);
|
||||||
|
} else {
|
||||||
|
setRange(null);
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={4}>
|
||||||
|
<Button
|
||||||
|
style={{ width: '100%', borderRadius: 6 }}
|
||||||
|
icon={<ClearOutlined />}
|
||||||
|
disabled={!hasFilters}
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
Limpar
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Text style={{ fontSize: 12, color: '#94A3B8' }}>
|
||||||
|
{data?.total !== undefined ? `${total.toLocaleString('pt-BR')} registros` : '…'}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 64 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '48px 0', textAlign: 'center' }}>
|
||||||
|
<ShoppingCartOutlined
|
||||||
|
style={{ fontSize: 56, color: '#D9E2EC', display: 'block', marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
<Text strong style={{ fontSize: 15, display: 'block' }}>
|
||||||
|
Nenhum registro encontrado
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary">Tente alterar os filtros de data ou busca.</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid #EBF0F5',
|
||||||
|
boxShadow: '0 1px 6px rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table<PedidoErpConsultaItem>
|
||||||
|
rowKey={(row) => `${row.tipo}-${row.numeroPedido}-${row.numeroEntrega ?? 0}`}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
size="middle"
|
||||||
|
loading={isFetching}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
onRow={(row) => ({
|
||||||
|
onClick: () => row.idPedido && setDrawerRow(row),
|
||||||
|
style: {
|
||||||
|
background: row.tipo === 'E' ? '#f0fdf4' : '#fff',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
cursor: row.idPedido ? 'pointer' : 'default',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize: limit,
|
||||||
|
total,
|
||||||
|
showSizeChanger: false,
|
||||||
|
showTotal: (t, [s, e]) => `Mostrando ${s}–${e} de ${t} registros`,
|
||||||
|
onChange: (p) => setPage(p),
|
||||||
|
style: { padding: '12px 24px' },
|
||||||
|
}}
|
||||||
|
style={{ borderRadius: 10, overflow: 'hidden' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ErpConsultaModal row={drawerRow} onClose={() => setDrawerRow(null)} />
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.ant-table-row:hover td { background: inherit !important; filter: brightness(0.97); }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1163
apps/web/src/cockpits/rep/OrdersPage.tsx
Normal file
1163
apps/web/src/cockpits/rep/OrdersPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
427
apps/web/src/cockpits/rep/ProductDetailDrawer.tsx
Normal file
427
apps/web/src/cockpits/rep/ProductDetailDrawer.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd';
|
||||||
|
import {
|
||||||
|
BarcodeOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
PercentageOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { PautaPreco } from '@sar/api-interface';
|
||||||
|
import { useProdutoDetail } from '../../lib/queries/catalog';
|
||||||
|
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
|
||||||
|
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
function fmtPrice(v: string | null | undefined): string {
|
||||||
|
const n = Number(v ?? 0);
|
||||||
|
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(v: string | null | undefined, decimals = 3): string {
|
||||||
|
const n = Number(v ?? 0);
|
||||||
|
return n > 0
|
||||||
|
? n.toLocaleString('pt-BR', {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
})
|
||||||
|
: '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bloco de seção ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<span style={{ color: '#003B8E', fontSize: 16 }}>{icon}</span>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#374151',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Campo label/valor ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
span = 1,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
span?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ gridColumn: span > 1 ? 'span 2' : undefined, marginBottom: 12 }}>
|
||||||
|
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', marginBottom: 2 }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13, color: '#1F2937' }}>{value ?? '—'}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card de preço ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PrecoCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
const n = Number(value ?? 0);
|
||||||
|
const empty = n <= 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: highlight && !empty ? '#F0FDF4' : '#F8FAFC',
|
||||||
|
border: `1px solid ${highlight && !empty ? '#BBF7D0' : '#EBF0F5'}`,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', marginBottom: 4 }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
color: empty ? '#CBD5E1' : highlight ? '#16A34A' : '#1F2937',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtPrice(value)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lista de pautas ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PautaPrecoList({ items }: { items: PautaPreco[] }) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '24px 16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#F8FAFC',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px dashed #E2E8F0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Produto não está em nenhuma pauta ativa.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.idPauta}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#F8FAFC',
|
||||||
|
border: '1px solid #EBF0F5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', lineHeight: 1.2 }}>
|
||||||
|
Pauta {item.codigo}
|
||||||
|
</Text>
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1F2937' }}>
|
||||||
|
{item.descricao}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ fontSize: 16, color: '#16A34A', fontVariantNumeric: 'tabular-nums' }}
|
||||||
|
>
|
||||||
|
{fmtPrice(item.preco)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Condições comerciais vigentes ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmtData(iso: string): string {
|
||||||
|
const [y, m, d] = iso.split('-');
|
||||||
|
return `${d}/${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CondicoesList({ conds }: { conds: CondicaoComercial[] }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{conds.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: c.tipo === 'promocao' ? '#FFFBE6' : '#F0F5FF',
|
||||||
|
border: `1px solid ${c.tipo === 'promocao' ? '#FFE58F' : '#ADC6FF'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', lineHeight: 1.2 }}>
|
||||||
|
{c.tipo === 'promocao' ? 'Promoção' : 'Condição especial'} · válida até{' '}
|
||||||
|
{fmtData(c.dataFim)}
|
||||||
|
</Text>
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1F2937' }}>
|
||||||
|
{c.descricao}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: c.tipo === 'promocao' ? '#D48806' : '#2F54EB',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.descPct.toLocaleString('pt-BR')}% desc.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal principal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
idErp: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
||||||
|
const { data, isLoading } = useProdutoDetail(idErp ?? undefined);
|
||||||
|
const condicoesDe = useCondicoesComerciais();
|
||||||
|
const conds = data ? condicoesDe(data) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={idErp != null}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={1100}
|
||||||
|
centered
|
||||||
|
title={null}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 14 }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── Cabeçalho ──────────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '24px 32px 20px',
|
||||||
|
borderBottom: '1px solid #F1F5F9',
|
||||||
|
background: '#FAFBFC',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text style={{ fontSize: 12, color: '#94A3B8', display: 'block', marginBottom: 4 }}>
|
||||||
|
Código {data.codigo.trim()}
|
||||||
|
{data.referencia ? ` · Ref. ${data.referencia.trim()}` : ''}
|
||||||
|
</Text>
|
||||||
|
<Title level={4} style={{ margin: 0, color: '#1F2937', lineHeight: 1.3 }}>
|
||||||
|
{data.descricao.trim()}
|
||||||
|
</Title>
|
||||||
|
{data.descricaoDetalhada && (
|
||||||
|
<Text style={{ fontSize: 13, color: '#64748B', display: 'block', marginTop: 4 }}>
|
||||||
|
{data.descricaoDetalhada.trim()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CondicaoTag conds={conds} />
|
||||||
|
{data.marca && <Tag color="blue">{data.marca.trim()}</Tag>}
|
||||||
|
{data.unidade && <Tag>{data.unidade}</Tag>}
|
||||||
|
<Tag color={data.ativo ? 'green' : 'red'}>{data.ativo ? 'Ativo' : 'Inativo'}</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Corpo ──────────────────────────────────────────────── */}
|
||||||
|
<div style={{ padding: '28px 32px', maxHeight: '65vh', overflowY: 'auto' }}>
|
||||||
|
<Row gutter={40}>
|
||||||
|
{/* Coluna esquerda */}
|
||||||
|
<Col xs={24} md={14}>
|
||||||
|
{/* Condições comerciais vigentes */}
|
||||||
|
{conds.length > 0 && (
|
||||||
|
<Section icon={<PercentageOutlined />} title="Condições Comerciais">
|
||||||
|
<CondicoesList conds={conds} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Classificação */}
|
||||||
|
<Section icon={<TagsOutlined />} title="Classificação">
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
||||||
|
<Field label="Grupo" value={data.grupo?.trim() ?? '—'} />
|
||||||
|
<Field label="Subgrupo" value={data.subgrupo?.trim() ?? '—'} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Preços Base */}
|
||||||
|
<Section icon={<TagsOutlined />} title="Preços Base">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PrecoCard label="Preço 1" value={data.vlPreco1} highlight />
|
||||||
|
<PrecoCard label="Preço 2" value={data.vlPreco2 ?? '0'} />
|
||||||
|
<PrecoCard label="Preço 3" value={data.vlPreco3 ?? '0'} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
|
||||||
|
<PrecoCard label="Promocional" value={data.precoPromocional ?? '0'} />
|
||||||
|
<PrecoCard label="Com IPI" value={data.precoComIpi ?? '0'} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#F8FAFC',
|
||||||
|
border: '1px solid #EBF0F5',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#94A3B8',
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Alíq. IPI
|
||||||
|
</Text>
|
||||||
|
<Text strong style={{ fontSize: 15, color: '#1F2937' }}>
|
||||||
|
{data.aliqIpi ? `${Number(data.aliqIpi).toLocaleString('pt-BR')}%` : '—'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Estoque & Logística */}
|
||||||
|
<Section icon={<InboxOutlined />} title="Estoque & Logística">
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Estoque"
|
||||||
|
value={data.qtdEstoque != null ? Number(data.qtdEstoque) : undefined}
|
||||||
|
valueStyle={{
|
||||||
|
fontSize: 22,
|
||||||
|
color:
|
||||||
|
data.qtdEstoque == null
|
||||||
|
? '#CBD5E1'
|
||||||
|
: Number(data.qtdEstoque) > 0
|
||||||
|
? '#1F2937'
|
||||||
|
: '#EF4444',
|
||||||
|
}}
|
||||||
|
formatter={(v) => (v != null ? Number(v).toLocaleString('pt-BR') : '—')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Lote múlt."
|
||||||
|
value={data.loteMulVenda ?? undefined}
|
||||||
|
valueStyle={{ fontSize: 22, color: '#1F2937' }}
|
||||||
|
formatter={(v) => (v != null ? String(v) : '—')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Peso líq. (kg)"
|
||||||
|
value={data.pesoLiquido ?? undefined}
|
||||||
|
valueStyle={{ fontSize: 22, color: '#1F2937' }}
|
||||||
|
formatter={(v) => fmtNum(String(v))}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Qtd. volume"
|
||||||
|
value={data.qtdVolume ?? undefined}
|
||||||
|
valueStyle={{ fontSize: 22, color: '#1F2937' }}
|
||||||
|
formatter={(v) => fmtNum(String(v), 0)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* Divisor vertical */}
|
||||||
|
<Col md={0} xs={24}>
|
||||||
|
<Divider style={{ margin: '4px 0 24px' }} />
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* Coluna direita — Pautas */}
|
||||||
|
<Col xs={24} md={10}>
|
||||||
|
<Section
|
||||||
|
icon={<BarcodeOutlined />}
|
||||||
|
title={`Preços por Pauta (${data.pautaPrecos.length})`}
|
||||||
|
>
|
||||||
|
<PautaPrecoList items={data.pautaPrecos} />
|
||||||
|
</Section>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
589
apps/web/src/cockpits/rep/RelatoriosPage.tsx
Normal file
589
apps/web/src/cockpits/rep/RelatoriosPage.tsx
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Progress,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { AlertOutlined, BarChartOutlined, RiseOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { CarteiraCliente, AbcProduto } from '@sar/api-interface';
|
||||||
|
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
function fmt(v: number | string): string {
|
||||||
|
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(v: string | null | undefined): string {
|
||||||
|
if (!v) return '—';
|
||||||
|
return new Date(v).toLocaleDateString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TabMeta() {
|
||||||
|
const now = dayjs();
|
||||||
|
const [mes, setMes] = useState(now.month() + 1);
|
||||||
|
const [ano, setAno] = useState(now.year());
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useReportMeta({ mes, ano });
|
||||||
|
|
||||||
|
const realizado = Number(data?.realizado ?? 0);
|
||||||
|
const meta = Number(data?.meta ?? 0);
|
||||||
|
const percentual = Number(data?.percentual ?? 0);
|
||||||
|
const comissao = Number(data?.comissaoEstimada ?? 0);
|
||||||
|
const bateuMeta = meta > 0 && realizado >= meta;
|
||||||
|
|
||||||
|
const progressColor = percentual >= 100 ? '#52c41a' : percentual >= 70 ? '#faad14' : '#003B8E';
|
||||||
|
|
||||||
|
const meses = [
|
||||||
|
'Jan',
|
||||||
|
'Fev',
|
||||||
|
'Mar',
|
||||||
|
'Abr',
|
||||||
|
'Mai',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Ago',
|
||||||
|
'Set',
|
||||||
|
'Out',
|
||||||
|
'Nov',
|
||||||
|
'Dez',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 20 }}>
|
||||||
|
<Select value={mes} onChange={setMes} style={{ width: 90 }}>
|
||||||
|
{meses.map((m, i) => (
|
||||||
|
<Option key={i + 1} value={i + 1}>
|
||||||
|
{m}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<DatePicker
|
||||||
|
picker="year"
|
||||||
|
value={dayjs().year(ano)}
|
||||||
|
onChange={(d) => d && setAno(d.year())}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
allowClear={false}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Erro ao carregar desempenho"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||||
|
styles={{ body: { padding: '24px 28px' } }}
|
||||||
|
>
|
||||||
|
<Row gutter={[32, 24]} align="middle">
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em' }}
|
||||||
|
>
|
||||||
|
Realizado {meses[mes - 1]}/{ano}
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
color: bateuMeta ? '#52c41a' : '#003B8E',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmt(realizado)}
|
||||||
|
</Text>
|
||||||
|
{bateuMeta && (
|
||||||
|
<Tag
|
||||||
|
color="success"
|
||||||
|
style={{ marginLeft: 10, fontWeight: 700, borderRadius: 20 }}
|
||||||
|
>
|
||||||
|
Meta batida!
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
de {fmt(meta)} de meta
|
||||||
|
</Text>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(percentual, 100)}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
strokeColor={progressColor}
|
||||||
|
format={() => `${percentual.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="Pedidos Transmitidos"
|
||||||
|
value={data?.totalPedidos ?? 0}
|
||||||
|
styles={{ content: { color: '#003B8E' } }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="Comissão Estimada"
|
||||||
|
value={fmt(comissao)}
|
||||||
|
styles={{ content: { color: '#52c41a', fontSize: 20 } }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="Falta para Meta"
|
||||||
|
value={meta > realizado ? fmt(meta - realizado) : 'Atingida'}
|
||||||
|
styles={{
|
||||||
|
content: { color: meta > realizado ? '#faad14' : '#52c41a', fontSize: 18 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="% Atingido"
|
||||||
|
value={`${percentual.toFixed(1)}%`}
|
||||||
|
styles={{ content: { color: progressColor, fontSize: 18 } }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{meta === 0 && (
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 8, border: '1px solid #fde68a', background: '#fffbeb' }}
|
||||||
|
styles={{ body: { padding: '12px 16px' } }}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<AlertOutlined style={{ color: '#d97706' }} />
|
||||||
|
<Text style={{ color: '#92400e' }}>
|
||||||
|
Nenhuma meta cadastrada para {meses[mes - 1]}/{ano}. Fale com seu supervisor.
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Aba 2: Carteira de Clientes ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
|
||||||
|
|
||||||
|
function TabCarteira() {
|
||||||
|
const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
|
||||||
|
const { data, isLoading, isError, error } = useReportCarteira();
|
||||||
|
|
||||||
|
const clientes = data?.data ?? [];
|
||||||
|
|
||||||
|
const filtered = clientes.filter((c: CarteiraCliente) => {
|
||||||
|
if (filtro === 'inativos30') return c.diasSemPedido != null && c.diasSemPedido >= 30;
|
||||||
|
if (filtro === 'inativos60') return c.diasSemPedido != null && c.diasSemPedido >= 60;
|
||||||
|
if (filtro === 'semPedido') return c.totalPedidos === 0;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function statusBadge(c: CarteiraCliente) {
|
||||||
|
if (c.totalPedidos === 0)
|
||||||
|
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedidos</Text>} />;
|
||||||
|
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="#ef4444"
|
||||||
|
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="#f59e0b"
|
||||||
|
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Inativo 30d+</Text>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<CarteiraCliente> = [
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
key: 'cliente',
|
||||||
|
render: (_: unknown, c: CarteiraCliente) => (
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Text strong style={{ fontSize: 13 }}>
|
||||||
|
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
|
||||||
|
</Text>
|
||||||
|
{c.razao && c.nome && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{c.nome}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
key: 'status',
|
||||||
|
width: 140,
|
||||||
|
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Último Pedido',
|
||||||
|
dataIndex: 'ultimoPedido',
|
||||||
|
width: 130,
|
||||||
|
render: (v: string | null) => (
|
||||||
|
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Dias Sem Pedido',
|
||||||
|
dataIndex: 'diasSemPedido',
|
||||||
|
width: 140,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number | null) =>
|
||||||
|
v != null ? (
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
|
||||||
|
>
|
||||||
|
{v}d
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">—</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'totalPedidos',
|
||||||
|
width: 90,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ticket Médio',
|
||||||
|
dataIndex: 'ticketMedio',
|
||||||
|
width: 130,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Erro ao carregar carteira de clientes"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data && (
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||||
|
{[
|
||||||
|
{ key: 'todos', label: 'Total', value: data.total, color: '#003B8E' },
|
||||||
|
{ key: 'inativos30', label: 'Inativos 30d+', value: data.inativos30, color: '#f59e0b' },
|
||||||
|
{ key: 'inativos60', label: 'Inativos 60d+', value: data.inativos60, color: '#ef4444' },
|
||||||
|
{ key: 'semPedido', label: 'Sem Pedido', value: data.semPedido, color: '#94A3B8' },
|
||||||
|
].map((item) => (
|
||||||
|
<Col key={item.key} xs={12} md={6}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={() => setFiltro(item.key as FiltroCarteira)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: filtro === item.key ? `2px solid ${item.color}` : '1px solid #EBF0F5',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '12px 16px' } }}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={item.label}
|
||||||
|
value={item.value}
|
||||||
|
styles={{ content: { color: item.color, fontSize: 24 } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table<CarteiraCliente>
|
||||||
|
rowKey="idCliente"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filtered}
|
||||||
|
loading={isLoading}
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 15, showSizeChanger: false }}
|
||||||
|
scroll={{ x: 700 }}
|
||||||
|
style={{ borderRadius: 10, overflow: 'hidden' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Aba 3: Curva ABC ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
|
||||||
|
|
||||||
|
function TabAbc() {
|
||||||
|
const now = dayjs();
|
||||||
|
const [mes, setMes] = useState<number | undefined>(now.month() + 1);
|
||||||
|
const [ano, setAno] = useState<number | undefined>(now.year());
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useReportAbc({ mes, ano });
|
||||||
|
|
||||||
|
const meses = [
|
||||||
|
'Jan',
|
||||||
|
'Fev',
|
||||||
|
'Mar',
|
||||||
|
'Abr',
|
||||||
|
'Mai',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Ago',
|
||||||
|
'Set',
|
||||||
|
'Out',
|
||||||
|
'Nov',
|
||||||
|
'Dez',
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: TableColumnsType<AbcProduto> = [
|
||||||
|
{
|
||||||
|
title: 'Curva',
|
||||||
|
dataIndex: 'curva',
|
||||||
|
width: 70,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Tag
|
||||||
|
color={CURVA_COLOR[v]}
|
||||||
|
style={{ fontWeight: 800, borderRadius: 20, fontSize: 13, padding: '0 10px' }}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Produto',
|
||||||
|
key: 'produto',
|
||||||
|
render: (_: unknown, p: AbcProduto) => (
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
{p.codProduto && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{p.codProduto}</Text>}
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: 500 }}>{p.descProduto}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total Faturado',
|
||||||
|
dataIndex: 'totalFaturado',
|
||||||
|
width: 150,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string, p: AbcProduto) => (
|
||||||
|
<Space orientation="vertical" size={0} style={{ alignItems: 'flex-end' }}>
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{Number(p.participacao).toFixed(1)}% do total
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Qtd. Vendida',
|
||||||
|
dataIndex: 'qtdVendida',
|
||||||
|
width: 110,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text className="tabular-nums">
|
||||||
|
{Number(v).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Desc. Médio',
|
||||||
|
dataIndex: 'descontoMedio',
|
||||||
|
width: 110,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text className="tabular-nums" style={{ color: Number(v) > 5 ? '#f59e0b' : '#475569' }}>
|
||||||
|
{Number(v).toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Comissão',
|
||||||
|
dataIndex: 'comissaoTotal',
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text className="tabular-nums" style={{ color: '#22c55e', fontWeight: 600 }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Erro ao carregar curva ABC"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 20 }}>
|
||||||
|
<Select
|
||||||
|
value={mes}
|
||||||
|
onChange={(v) => setMes(v)}
|
||||||
|
allowClear
|
||||||
|
placeholder="Mês"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
onClear={() => setMes(undefined)}
|
||||||
|
>
|
||||||
|
{meses.map((m, i) => (
|
||||||
|
<Option key={i + 1} value={i + 1}>
|
||||||
|
{m}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<DatePicker
|
||||||
|
picker="year"
|
||||||
|
value={ano ? dayjs().year(ano) : null}
|
||||||
|
onChange={(d) => setAno(d?.year())}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
placeholder="Ano"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
{data && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Período: <strong>{data.periodo}</strong> — Total:{' '}
|
||||||
|
<strong>{fmt(data.totalFaturado)}</strong>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table<AbcProduto>
|
||||||
|
rowKey={(r) => r.codProduto ?? r.descProduto}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||||
|
scroll={{ x: 800 }}
|
||||||
|
rowClassName={(r) => (r.curva === 'A' ? 'row-curva-a' : '')}
|
||||||
|
style={{ borderRadius: 10, overflow: 'hidden' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.row-curva-a td { background: #f0f5ff !important; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── RelatoriosPage ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function RelatoriosPage() {
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: 'meta',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<RiseOutlined />
|
||||||
|
Desempenho vs. Meta
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: <TabMeta />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'carteira',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<TeamOutlined />
|
||||||
|
Carteira de Clientes
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: <TabCarteira />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'abc',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<BarChartOutlined />
|
||||||
|
Curva ABC
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: <TabAbc />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
Relatórios
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||||
|
Análise de desempenho, carteira e mix de produtos.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultActiveKey="meta" items={tabs} size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
696
apps/web/src/cockpits/rep/RepPainel.tsx
Normal file
696
apps/web/src/cockpits/rep/RepPainel.tsx
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Flex,
|
||||||
|
Progress,
|
||||||
|
Row,
|
||||||
|
Segmented,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faArrowTrendUp,
|
||||||
|
faBullseye,
|
||||||
|
faChartBar,
|
||||||
|
faCircleExclamation,
|
||||||
|
faClipboardList,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { WhatsAppOutlined } from '@ant-design/icons';
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Tooltip as ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Chart } from 'react-chartjs-2';
|
||||||
|
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface';
|
||||||
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
|
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||||
|
import { useCurrentUser } from '../../lib/queries/auth';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const SITUA_COLOR: Record<number, string> = {
|
||||||
|
1: 'warning',
|
||||||
|
2: 'processing',
|
||||||
|
3: 'error',
|
||||||
|
4: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(v: number): string {
|
||||||
|
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function greeting(): string {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h < 12) return 'Bom dia';
|
||||||
|
if (h < 18) return 'Boa tarde';
|
||||||
|
return 'Boa noite';
|
||||||
|
}
|
||||||
|
|
||||||
|
function today(): string {
|
||||||
|
return new Date().toLocaleDateString('pt-BR', { day: 'numeric', month: 'long' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: number, dec = 0): string {
|
||||||
|
return v.toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
||||||
|
}
|
||||||
|
|
||||||
|
function waLink(whatsapp: string): string {
|
||||||
|
const digits = whatsapp.replace(/\D/g, '');
|
||||||
|
const number = digits.startsWith('55') ? digits : `55${digits}`;
|
||||||
|
return `https://wa.me/${number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaCell({
|
||||||
|
real,
|
||||||
|
meta,
|
||||||
|
money,
|
||||||
|
dec = 0,
|
||||||
|
}: {
|
||||||
|
real: number;
|
||||||
|
meta: number;
|
||||||
|
money?: boolean;
|
||||||
|
dec?: number;
|
||||||
|
}) {
|
||||||
|
const f = (v: number) => (money ? fmt(v) : num(v, dec));
|
||||||
|
const ok = meta > 0 && real >= meta;
|
||||||
|
return (
|
||||||
|
<Space orientation="vertical" size={0} style={{ lineHeight: 1.15 }}>
|
||||||
|
<Text strong className="tabular-nums" style={{ color: ok ? 'var(--green)' : undefined }}>
|
||||||
|
{f(real)}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" className="tabular-nums" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
/ {f(meta)}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaColumns: TableColumnsType<MetaItem> = [
|
||||||
|
{
|
||||||
|
title: 'Grupo',
|
||||||
|
dataIndex: 'rotulo',
|
||||||
|
key: 'rotulo',
|
||||||
|
fixed: 'left',
|
||||||
|
width: 180,
|
||||||
|
render: (v: string) => <Text strong>{v}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'pedidos',
|
||||||
|
key: 'pedidos',
|
||||||
|
align: 'right',
|
||||||
|
width: 80,
|
||||||
|
render: (v: number) => <span className="tabular-nums">{num(v)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Qtde',
|
||||||
|
key: 'qtd',
|
||||||
|
align: 'right',
|
||||||
|
width: 110,
|
||||||
|
render: (_: unknown, r: MetaItem) => <MetaCell real={r.qtdReal} meta={r.qtdMeta} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Peso (kg)',
|
||||||
|
key: 'peso',
|
||||||
|
align: 'right',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, r: MetaItem) => <MetaCell real={r.pesoReal} meta={r.pesoMeta} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Valor',
|
||||||
|
key: 'valor',
|
||||||
|
align: 'right',
|
||||||
|
width: 160,
|
||||||
|
render: (_: unknown, r: MetaItem) => <MetaCell real={r.valorReal} meta={r.valorMeta} money />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fator (R$/kg)',
|
||||||
|
key: 'fator',
|
||||||
|
align: 'right',
|
||||||
|
width: 110,
|
||||||
|
render: (_: unknown, r: MetaItem) => <MetaCell real={r.fatorReal} meta={r.fatorMeta} dec={2} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% da meta (valor)',
|
||||||
|
key: 'pct',
|
||||||
|
align: 'center',
|
||||||
|
width: 160,
|
||||||
|
render: (_: unknown, r: MetaItem) => (
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(r.pct, 100)}
|
||||||
|
size="small"
|
||||||
|
format={() => `${r.pct}%`}
|
||||||
|
strokeColor={r.pct >= 100 ? 'var(--green)' : 'var(--jcs-blue)'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Gráfico anual ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MESES = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
|
||||||
|
|
||||||
|
function GraficoAnual({
|
||||||
|
dados,
|
||||||
|
ano,
|
||||||
|
mesAtual,
|
||||||
|
}: {
|
||||||
|
dados: MesAno[];
|
||||||
|
ano: number;
|
||||||
|
mesAtual: number;
|
||||||
|
}) {
|
||||||
|
const labels = MESES;
|
||||||
|
|
||||||
|
const barColors = dados.map((d) => {
|
||||||
|
if (d.mes > mesAtual) return 'rgba(203,213,225,0.5)'; // futuro — cinza claro
|
||||||
|
if (d.mes === mesAtual) return d.atingiu ? 'rgba(56,158,13,0.75)' : 'rgba(0,59,142,0.75)'; // mês atual
|
||||||
|
return d.atingiu ? 'rgba(56,158,13,0.85)' : 'rgba(0,59,142,0.65)'; // passado
|
||||||
|
});
|
||||||
|
|
||||||
|
const borderColors = dados.map((d) => (d.mes === mesAtual ? '#1a1a1a' : 'transparent'));
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
type: 'bar' as const,
|
||||||
|
label: 'Realizado',
|
||||||
|
data: dados.map((d) => (d.mes <= mesAtual ? d.valor : null)),
|
||||||
|
backgroundColor: barColors,
|
||||||
|
borderColor: borderColors,
|
||||||
|
borderWidth: dados.map((d) => (d.mes === mesAtual ? 2 : 0)),
|
||||||
|
borderRadius: 4,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'line' as const,
|
||||||
|
label: 'Meta',
|
||||||
|
data: dados.map((d) => (d.meta > 0 ? d.meta : null)),
|
||||||
|
borderColor: '#f5222d',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 4],
|
||||||
|
pointRadius: dados.map((d) => (d.meta > 0 ? 4 : 0)),
|
||||||
|
pointBackgroundColor: dados.map((d) =>
|
||||||
|
d.meta > 0 && d.mes <= mesAtual ? (d.atingiu ? '#52c41a' : '#f5222d') : '#f5222d',
|
||||||
|
),
|
||||||
|
order: 1,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index' as const, intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
||||||
|
const val = ctx.parsed.y;
|
||||||
|
if (val == null) return '';
|
||||||
|
return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
callback: (v: number | string) =>
|
||||||
|
Number(v).toLocaleString('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL',
|
||||||
|
notation: 'compact',
|
||||||
|
}),
|
||||||
|
font: { size: 10 },
|
||||||
|
},
|
||||||
|
grid: { color: '#f0f0f0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faChartBar} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Realizado vs Meta — {ano}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ height: 240 }}>
|
||||||
|
<Chart type="bar" data={chartData} options={options} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Não positivados ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type OrdemNP = 'longe' | 'recentes';
|
||||||
|
|
||||||
|
function NaoPositivados({ clientes, total }: { clientes: ClienteNaoPositivado[]; total: number }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [ordem, setOrdem] = useState<OrdemNP>('longe');
|
||||||
|
const [expandido, setExpandido] = useState(false);
|
||||||
|
const PAGE = 10;
|
||||||
|
|
||||||
|
const sorted = [...clientes].sort((a, b) => {
|
||||||
|
if (ordem === 'longe') {
|
||||||
|
return (b.diasSemPedido ?? 999) - (a.diasSemPedido ?? 999);
|
||||||
|
}
|
||||||
|
// 'recentes': compraram antes primeiro, ordenados por último pedido desc
|
||||||
|
if (a.comprouAntes && !b.comprouAntes) return -1;
|
||||||
|
if (!a.comprouAntes && b.comprouAntes) return 1;
|
||||||
|
const da = a.ultimoPedido ?? '';
|
||||||
|
const db = b.ultimoPedido ?? '';
|
||||||
|
return db.localeCompare(da);
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = expandido ? sorted : sorted.slice(0, PAGE);
|
||||||
|
const restante = total - PAGE;
|
||||||
|
|
||||||
|
const columns: TableColumnsType<ClienteNaoPositivado> = [
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
key: 'cliente',
|
||||||
|
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ cursor: 'pointer', color: 'var(--jcs-blue)' }}
|
||||||
|
onClick={() =>
|
||||||
|
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{c.razao ?? c.nome}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Último pedido',
|
||||||
|
key: 'ultimo',
|
||||||
|
width: 160,
|
||||||
|
render: (_: unknown, c: ClienteNaoPositivado) => {
|
||||||
|
if (!c.comprouAntes) {
|
||||||
|
return (
|
||||||
|
<Tag color="default" style={{ borderRadius: 20 }}>
|
||||||
|
nunca comprou
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const dias = c.diasSemPedido ?? 999;
|
||||||
|
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
|
||||||
|
return (
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
|
||||||
|
{dias}d sem pedido
|
||||||
|
</Tag>
|
||||||
|
{c.ultimoPedido && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{new Date(c.ultimoPedido).toLocaleDateString('pt-BR')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'acoes',
|
||||||
|
width: 96,
|
||||||
|
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||||
|
<Space size={6}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{ borderRadius: 6 }}
|
||||||
|
onClick={() =>
|
||||||
|
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Ficha
|
||||||
|
</Button>
|
||||||
|
{c.whatsapp && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
style={{ borderRadius: 6, background: '#25d366', borderColor: '#25d366' }}
|
||||||
|
icon={<WhatsAppOutlined />}
|
||||||
|
href={waLink(c.whatsapp)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
|
||||||
|
Não positivados no mês
|
||||||
|
{total > 0 && (
|
||||||
|
<Tag color="orange" style={{ borderRadius: 20, fontWeight: 700 }}>
|
||||||
|
{total}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Segmented<OrdemNP>
|
||||||
|
size="small"
|
||||||
|
value={ordem}
|
||||||
|
onChange={setOrdem}
|
||||||
|
options={[
|
||||||
|
{ label: 'Mais longe', value: 'longe' },
|
||||||
|
{ label: 'Compraram antes', value: 'recentes' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{clientes.length === 0 ? (
|
||||||
|
<Text type="secondary">Todos os clientes já positivados este mês. Ótimo trabalho!</Text>
|
||||||
|
) : (
|
||||||
|
<Flex vertical gap={12}>
|
||||||
|
<Table<ClienteNaoPositivado>
|
||||||
|
rowKey="idCliente"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={visible}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
showHeader={false}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
{!expandido && total > PAGE && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ alignSelf: 'flex-start', padding: 0 }}
|
||||||
|
onClick={() => setExpandido(true)}
|
||||||
|
>
|
||||||
|
Ver mais {restante} cliente{restante !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{expandido && total > PAGE && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ alignSelf: 'flex-start', padding: 0 }}
|
||||||
|
onClick={() => setExpandido(false)}
|
||||||
|
>
|
||||||
|
Recolher
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── RepPainel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function RepPainel() {
|
||||||
|
const { data, isLoading } = useRepDashboard();
|
||||||
|
const { data: user } = useCurrentUser();
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 2 }} />
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
<Col xs={24} md={14}>
|
||||||
|
<Skeleton active />
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={10}>
|
||||||
|
<Skeleton active />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
meta,
|
||||||
|
metasPorGrupo = [],
|
||||||
|
pedidosMes,
|
||||||
|
pedidosRecentes = [],
|
||||||
|
naoPositivados = [],
|
||||||
|
totalNaoPositivados = 0,
|
||||||
|
historicoMensal = [],
|
||||||
|
syncedAt,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const anoAtual = now.getFullYear();
|
||||||
|
const mesAtual = now.getMonth() + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
{/* Saudação */}
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
|
{greeting()}, {user?.nome?.split(' ')[0] ?? '...'}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
|
{today()}
|
||||||
|
{totalNaoPositivados > 0 && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
·{' '}
|
||||||
|
<span style={{ color: 'var(--orange)' }}>
|
||||||
|
{totalNaoPositivados} clientes sem pedido no mês
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Linha 1 — Meta + Pedidos do mês */}
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
<Col xs={24} md={14}>
|
||||||
|
<Card style={{ height: '100%' }}>
|
||||||
|
<Flex vertical gap={16}>
|
||||||
|
<Flex justify="space-between" align="flex-start">
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
META DO MÊS
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{fmt(meta.atingido)}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
de <span className="tabular-nums">{fmt(meta.total)}</span>
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Tag
|
||||||
|
color={meta.pct >= 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'}
|
||||||
|
>
|
||||||
|
{meta.pct}% atingido
|
||||||
|
</Tag>
|
||||||
|
</Flex>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(meta.pct, 100)}
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor="var(--jcs-blue)"
|
||||||
|
railColor="var(--jcs-blue-light)"
|
||||||
|
/>
|
||||||
|
{meta.falta > 0 ? (
|
||||||
|
<Text style={{ fontSize: 'var(--text-md)' }}>
|
||||||
|
Faltam <strong className="tabular-nums">{fmt(meta.falta)}</strong> pra fechar o
|
||||||
|
mês.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={{ fontSize: 'var(--text-md)', color: 'var(--green)' }}>
|
||||||
|
Meta batida! Comissão FLEX ativa.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} md={10}>
|
||||||
|
<Card style={{ height: '100%' }}>
|
||||||
|
<Space orientation="vertical" size={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
PEDIDOS NO MÊS
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{pedidosMes}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
<FontAwesomeIcon icon={faArrowTrendUp} /> mês corrente
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Metas por Grupo */}
|
||||||
|
{metasPorGrupo.length > 0 && (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faBullseye} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Metas por Grupo
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Tag color={meta.pct >= 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'}>
|
||||||
|
{meta.pct}% no valor total
|
||||||
|
</Tag>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<MetaItem>
|
||||||
|
rowKey={(r) => String(r.codigo)}
|
||||||
|
columns={metaColumns}
|
||||||
|
dataSource={metasPorGrupo}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 820 }}
|
||||||
|
summary={(rows) => {
|
||||||
|
const t = rows.reduce(
|
||||||
|
(a, r) => ({
|
||||||
|
pedidos: a.pedidos + r.pedidos,
|
||||||
|
qtdReal: a.qtdReal + r.qtdReal,
|
||||||
|
qtdMeta: a.qtdMeta + r.qtdMeta,
|
||||||
|
pesoReal: a.pesoReal + r.pesoReal,
|
||||||
|
pesoMeta: a.pesoMeta + r.pesoMeta,
|
||||||
|
valorReal: a.valorReal + r.valorReal,
|
||||||
|
valorMeta: a.valorMeta + r.valorMeta,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
pedidos: 0,
|
||||||
|
qtdReal: 0,
|
||||||
|
qtdMeta: 0,
|
||||||
|
pesoReal: 0,
|
||||||
|
pesoMeta: 0,
|
||||||
|
valorReal: 0,
|
||||||
|
valorMeta: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const pctTotal = t.valorMeta > 0 ? Math.round((t.valorReal / t.valorMeta) * 100) : 0;
|
||||||
|
const fatorReal = t.pesoReal > 0 ? t.valorReal / t.pesoReal : 0;
|
||||||
|
const fatorMeta = t.pesoMeta > 0 ? t.valorMeta / t.pesoMeta : 0;
|
||||||
|
return (
|
||||||
|
<Table.Summary.Row style={{ background: 'var(--bg-surface-alt)' }}>
|
||||||
|
<Table.Summary.Cell index={0}>
|
||||||
|
<Text strong>Total</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={1} align="right">
|
||||||
|
<span className="tabular-nums">{num(t.pedidos)}</span>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={2} align="right">
|
||||||
|
<MetaCell real={t.qtdReal} meta={t.qtdMeta} />
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={3} align="right">
|
||||||
|
<MetaCell real={t.pesoReal} meta={t.pesoMeta} />
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={4} align="right">
|
||||||
|
<MetaCell real={t.valorReal} meta={t.valorMeta} money />
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={5} align="right">
|
||||||
|
<MetaCell real={fatorReal} meta={fatorMeta} dec={2} />
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={6} align="center">
|
||||||
|
<Text strong>{pctTotal}%</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
</Table.Summary.Row>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gráfico anual */}
|
||||||
|
<GraficoAnual dados={historicoMensal} ano={anoAtual} mesAtual={mesAtual} />
|
||||||
|
|
||||||
|
{/* Linha 2 — Não positivados + Pedidos recentes */}
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<NaoPositivados clientes={naoPositivados} total={totalNaoPositivados} />
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Pedidos recentes
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={<Link to="/pedidos">Ver todos</Link>}
|
||||||
|
>
|
||||||
|
{pedidosRecentes.length === 0 ? (
|
||||||
|
<Text type="secondary">Nenhum pedido nos últimos 7 dias.</Text>
|
||||||
|
) : (
|
||||||
|
<Flex vertical gap={10}>
|
||||||
|
{pedidosRecentes.map((o: PedidoSummary) => (
|
||||||
|
<Flex key={o.id} justify="space-between" align="center">
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Link to="/pedidos/$id" params={{ id: o.id }}>
|
||||||
|
<Text strong className="tabular-nums">
|
||||||
|
{o.numPedSar}
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{o.razaoCliente ?? o.nomeCliente ?? `Cód. cliente ${o.idCliente}`}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Flex gap={8} align="center">
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
{Number(o.total).toLocaleString('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Tag color={SITUA_COLOR[o.situa] ?? 'default'}>
|
||||||
|
{SITUA_LABEL[o.situa] ?? String(o.situa)}
|
||||||
|
</Tag>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Flex justify="space-between" style={{ paddingTop: 8 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
SAR · Força de Vendas · Powered by JCS Sistemas
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/web/src/components/CondicaoTag.tsx
Normal file
27
apps/web/src/components/CondicaoTag.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Tag } from 'antd';
|
||||||
|
import type { CondicaoComercial } from '../lib/condicoes-comerciais';
|
||||||
|
|
||||||
|
// Selo que sinaliza produto com proposta comercial diferenciada (promoção do
|
||||||
|
// gerente ou regra de desconto vigente). O detalhamento fica no modal do produto.
|
||||||
|
export function CondicaoTag({ conds, compact }: { conds: CondicaoComercial[]; compact?: boolean }) {
|
||||||
|
if (conds.length === 0) return null;
|
||||||
|
const temPromo = conds.some((c) => c.tipo === 'promocao');
|
||||||
|
const temRegra = conds.some((c) => c.tipo === 'regra');
|
||||||
|
const style = compact
|
||||||
|
? { fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '16px' }
|
||||||
|
: { fontSize: 11, margin: 0 };
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{temPromo && (
|
||||||
|
<Tag color="gold" style={style}>
|
||||||
|
Promoção
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{temRegra && (
|
||||||
|
<Tag color="geekblue" style={style}>
|
||||||
|
Cond. especial
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
apps/web/src/components/contacts/ClientContacts.tsx
Normal file
248
apps/web/src/components/contacts/ClientContacts.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import {
|
||||||
|
App,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Divider,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { MailOutlined, PhoneOutlined, WhatsAppOutlined } from '@ant-design/icons';
|
||||||
|
import type { Contato } from '@sar/api-interface';
|
||||||
|
import { useClientContacts, useCreateContato } from '../../lib/queries/clients';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
function ContactActions({ contato }: { contato: Contato }) {
|
||||||
|
const wa = contato.whatsapp?.trim() || contato.celular?.trim();
|
||||||
|
const tel = contato.telefone?.trim();
|
||||||
|
const mail = contato.email?.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{wa && (
|
||||||
|
<Tooltip title={`WhatsApp: ${wa}`}>
|
||||||
|
<a href={`https://wa.me/55${wa.replace(/\D/g, '')}`} target="_blank" rel="noreferrer">
|
||||||
|
<Tag
|
||||||
|
icon={<WhatsAppOutlined />}
|
||||||
|
color="#25D366"
|
||||||
|
style={{ cursor: 'pointer', margin: 0 }}
|
||||||
|
>
|
||||||
|
{wa}
|
||||||
|
</Tag>
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{tel && !wa && (
|
||||||
|
<Tooltip title={`Telefone: ${tel}`}>
|
||||||
|
<Tag icon={<PhoneOutlined />} color="blue" style={{ margin: 0 }}>
|
||||||
|
{tel}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{mail && (
|
||||||
|
<Tooltip title={mail}>
|
||||||
|
<a href={`mailto:${mail}`}>
|
||||||
|
<Tag icon={<MailOutlined />} color="default" style={{ margin: 0 }}>
|
||||||
|
{mail.length > 22 ? mail.slice(0, 20) + '…' : mail}
|
||||||
|
</Tag>
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
idCliente: number;
|
||||||
|
modalOpen?: boolean;
|
||||||
|
onModalClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: Props) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const open = modalOpen;
|
||||||
|
const setOpen = (v: boolean) => {
|
||||||
|
if (!v) onModalClose?.();
|
||||||
|
};
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const { data: contacts = [], isLoading } = useClientContacts(idCliente);
|
||||||
|
const createMutation = useCreateContato(idCliente);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const payload = {
|
||||||
|
nome: String(values.nome),
|
||||||
|
empresa: values.empresa || undefined,
|
||||||
|
cargo: values.cargo || undefined,
|
||||||
|
departamento: values.departamento || undefined,
|
||||||
|
dtAniversario: values.dtAniversario
|
||||||
|
? (values.dtAniversario as { format: (f: string) => string }).format('YYYY-MM-DD')
|
||||||
|
: undefined,
|
||||||
|
telefone: values.telefone || undefined,
|
||||||
|
celular: values.celular || undefined,
|
||||||
|
whatsapp: values.whatsapp || undefined,
|
||||||
|
email: values.email || undefined,
|
||||||
|
anotacoes: values.anotacoes || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||||
|
try {
|
||||||
|
result = await createMutation.mutateAsync(payload);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.sincronizado && result.erroSync) {
|
||||||
|
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);
|
||||||
|
} else {
|
||||||
|
void message.success(`Contato cadastrado! Código ERP: ${result.idContatoErp ?? '—'}`);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider titlePlacement="left" style={{ marginBottom: 12 }}>
|
||||||
|
Contatos
|
||||||
|
</Divider>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Spin style={{ display: 'block', margin: '16px auto' }} />
|
||||||
|
) : contacts.length === 0 ? (
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ display: 'block', marginBottom: 24, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
Nenhum contato cadastrado.
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contacts.map((c: Contato) => (
|
||||||
|
<Card
|
||||||
|
key={c.idContato}
|
||||||
|
size="small"
|
||||||
|
styles={{ body: { padding: '10px 12px' } }}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: 13, display: 'block', marginBottom: 2 }}>
|
||||||
|
{c.nome}
|
||||||
|
</Text>
|
||||||
|
{(c.cargo || c.departamento) && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 6 }}>
|
||||||
|
{[c.cargo, c.departamento].filter(Boolean).join(' · ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<ContactActions contato={c} />
|
||||||
|
{c.dtAniversario && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>
|
||||||
|
Aniversário: {new Date(c.dtAniversario + 'T12:00:00').toLocaleDateString('pt-BR')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="Novo Contato"
|
||||||
|
open={open}
|
||||||
|
onOk={() => void handleSave()}
|
||||||
|
onCancel={() => {
|
||||||
|
setOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
okText="Cadastrar"
|
||||||
|
cancelText="Cancelar"
|
||||||
|
width={580}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }} requiredMark="optional">
|
||||||
|
<Form.Item
|
||||||
|
name="nome"
|
||||||
|
label="Nome"
|
||||||
|
rules={[{ required: true, message: 'Informe o nome' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Nome do contato" maxLength={100} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="cargo" label="Cargo">
|
||||||
|
<Input placeholder="Comprador, Gerente..." maxLength={100} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="departamento" label="Departamento">
|
||||||
|
<Input placeholder="Compras, Financeiro..." maxLength={100} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="whatsapp" label="WhatsApp">
|
||||||
|
<Input
|
||||||
|
placeholder="48999990000"
|
||||||
|
maxLength={20}
|
||||||
|
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="celular" label="Celular">
|
||||||
|
<Input placeholder="48999990000" maxLength={20} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="telefone" label="Telefone fixo">
|
||||||
|
<Input placeholder="4833330000" maxLength={20} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="email" label="E-mail">
|
||||||
|
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="dtAniversario" label="Aniversário">
|
||||||
|
<DatePicker
|
||||||
|
format="DD/MM/YYYY"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="dd/mm/aaaa"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item name="anotacoes" label="Anotações">
|
||||||
|
<Input.TextArea rows={2} placeholder="Observações sobre o contato..." />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/web/src/components/dev/DevLogin.tsx
Normal file
74
apps/web/src/components/dev/DevLogin.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Componente de login dev — visível apenas quando NODE_ENV !== 'production' e sem token.
|
||||||
|
// Em produção o token vem do master-login real (fora do escopo do MVP).
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Button, Card, Divider, Flex, Space, Typography } from 'antd';
|
||||||
|
import { apiFetch } from '../../lib/api-client';
|
||||||
|
import { authStore } from '../../lib/auth-store';
|
||||||
|
import { AuthTokenResponseSchema } from '@sar/api-interface';
|
||||||
|
|
||||||
|
type DevUser = { key: string; userId: string; role: string; label: string };
|
||||||
|
|
||||||
|
// userId = cod_vendedor como string; idEmpresa fica a cargo do backend (DEV_EMPRESA_ID).
|
||||||
|
// Usuários provisórios para testar as telas por papel enquanto o cadastro de
|
||||||
|
// usuários não existe: Pavei (carteira própria), Sidnei (equipe via
|
||||||
|
// vw_representantes.cod_supervisor = 191), Lucas (empresa toda).
|
||||||
|
const DEV_USERS: DevUser[] = [
|
||||||
|
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Pavei — Representante (cód. 29)' },
|
||||||
|
{ key: 'sup-191', userId: '191', role: 'supervisor', label: 'Sidnei — Supervisor (cód. 191)' },
|
||||||
|
{ key: 'mgr-156', userId: '156', role: 'manager', label: 'Lucas — Gerente/Admin (cód. 156)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
||||||
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleLogin(user: DevUser) {
|
||||||
|
setLoading(user.key);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const raw = await apiFetch('/auth/dev/token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { userId: user.userId, role: user.role },
|
||||||
|
});
|
||||||
|
const { accessToken } = AuthTokenResponseSchema.parse(raw);
|
||||||
|
authStore.set(accessToken);
|
||||||
|
onLogin();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Erro ao obter token');
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex justify="center" align="center" style={{ minHeight: '100vh' }}>
|
||||||
|
<Card style={{ width: 380 }}>
|
||||||
|
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||||
|
SAR · Login Dev
|
||||||
|
</Typography.Title>
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
title="Ambiente de desenvolvimento"
|
||||||
|
description="Este login automático não existe em produção."
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
{error && <Alert type="error" title={error} showIcon />}
|
||||||
|
<Divider style={{ margin: '4px 0' }}>Entrar como</Divider>
|
||||||
|
{DEV_USERS.map((u) => (
|
||||||
|
<Button
|
||||||
|
key={u.key}
|
||||||
|
block
|
||||||
|
type={u.role === 'rep' ? 'primary' : 'default'}
|
||||||
|
loading={loading === u.key}
|
||||||
|
onClick={() => void handleLogin(u)}
|
||||||
|
>
|
||||||
|
{u.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +1,91 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useEffect, type ReactNode } from 'react';
|
||||||
import { Flex } from 'antd';
|
import { Alert, App, Button, Flex, Grid, Tooltip } from 'antd';
|
||||||
|
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Topbar } from './Topbar';
|
import { Topbar } from './Topbar';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { BottomNav } from './BottomNav';
|
||||||
|
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
||||||
|
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
||||||
|
import { registerMessageApi } from '../../lib/feedback';
|
||||||
|
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* AppShell canônico do SAR — topbar 80 + sidebar 260 + área de conteúdo.
|
|
||||||
* Layout para cockpits Sandra/Daniel/Alice (desktop-first).
|
|
||||||
* Variante mobile (Rafael) com bottom nav virá em ShellMobile separado.
|
|
||||||
*/
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
const navigate = useNavigate();
|
||||||
const [_sidebarOpen, setSidebarOpen] = useState(true);
|
const isOnline = useNetworkStatus();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
|
||||||
|
const isDesktop = !!screens.lg;
|
||||||
|
useOfflineSync();
|
||||||
|
|
||||||
|
// Disponibiliza o message (com tema) para os caches do TanStack Query
|
||||||
|
useEffect(() => {
|
||||||
|
registerMessageApi(message);
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
|
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
|
||||||
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
|
{!isOnline && (
|
||||||
<Flex flex={1}>
|
<Alert
|
||||||
<Sidebar />
|
type="warning"
|
||||||
|
icon={<WifiOutlined />}
|
||||||
|
showIcon
|
||||||
|
banner
|
||||||
|
message="Sem conexão — pedidos lançados ficam salvos e serão enviados ao reconectar"
|
||||||
|
style={{ padding: '6px 16px', fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Topbar />
|
||||||
|
<Flex flex={1} style={{ overflow: 'hidden' }}>
|
||||||
|
{isDesktop && <Sidebar />}
|
||||||
<main
|
<main
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 'var(--space-xl)',
|
padding: isDesktop ? 'var(--space-xl)' : 'var(--space-md)',
|
||||||
overflow: 'auto',
|
paddingBottom: isDesktop
|
||||||
maxWidth: '100%',
|
? 'var(--space-xl)'
|
||||||
|
: 'calc(var(--layout-bottom-nav-height) + var(--space-lg))',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{!isDesktop && <BottomNav />}
|
||||||
|
|
||||||
|
{/* FAB — Novo Pedido: acima do bottom nav em mobile */}
|
||||||
|
<Tooltip title="Novo Pedido" placement="left">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => void navigate({ to: '/pedidos/novo' })}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: isDesktop ? 32 : 'calc(var(--layout-bottom-nav-height) + 16px)',
|
||||||
|
right: 20,
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
fontSize: 22,
|
||||||
|
backgroundColor: '#389e0d',
|
||||||
|
borderColor: '#389e0d',
|
||||||
|
boxShadow: '0 4px 16px rgba(56,158,13,0.45)',
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
119
apps/web/src/components/layout/BottomNav.tsx
Normal file
119
apps/web/src/components/layout/BottomNav.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faChartLine,
|
||||||
|
faClipboardList,
|
||||||
|
faClipboardCheck,
|
||||||
|
faUsers,
|
||||||
|
faAddressCard,
|
||||||
|
faBoxesStacked,
|
||||||
|
faFileInvoiceDollar,
|
||||||
|
faTags,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { authStore } from '../../lib/auth-store';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
key: string;
|
||||||
|
icon: IconDefinition;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRole(): string {
|
||||||
|
const token = authStore.get();
|
||||||
|
if (!token) return 'rep';
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1] ?? ''));
|
||||||
|
return (payload.role as string) ?? 'rep';
|
||||||
|
} catch {
|
||||||
|
return 'rep';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REP_ITEMS: NavItem[] = [
|
||||||
|
{ key: '/', icon: faChartLine, label: 'Painel' },
|
||||||
|
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
||||||
|
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
|
||||||
|
{ key: '/catalogo', icon: faBoxesStacked, label: 'Catálogo' },
|
||||||
|
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUPERVISOR_ITEMS: NavItem[] = [
|
||||||
|
{ key: '/', icon: faChartLine, label: 'Painel' },
|
||||||
|
{ key: '/aprovacoes', icon: faClipboardCheck, label: 'Aprovações' },
|
||||||
|
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
|
||||||
|
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
||||||
|
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GERENTE_ITEMS: NavItem[] = [
|
||||||
|
{ key: '/ger', icon: faChartLine, label: 'Painel' },
|
||||||
|
{ key: '/ger/equipe', icon: faUsers, label: 'Equipe' },
|
||||||
|
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
||||||
|
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
||||||
|
{ key: '/ger/politicas', icon: faTags, label: 'Políticas' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getItems(role: string): NavItem[] {
|
||||||
|
if (role === 'manager' || role === 'admin') return GERENTE_ITEMS;
|
||||||
|
if (role === 'supervisor') return SUPERVISOR_ITEMS;
|
||||||
|
return REP_ITEMS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const role = getRole();
|
||||||
|
const items = getItems(role);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 'var(--layout-bottom-nav-height)',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
borderTop: '1px solid var(--border-subtle)',
|
||||||
|
display: 'flex',
|
||||||
|
zIndex: 'var(--z-fixed)' as unknown as number,
|
||||||
|
boxShadow: '0 -2px 12px rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.key === '/' || item.key === '/ger'
|
||||||
|
? location.pathname === item.key
|
||||||
|
: location.pathname.startsWith(item.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => void navigate({ to: item.key })}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 3,
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '6px 4px',
|
||||||
|
color: isActive ? 'var(--jcs-blue)' : 'var(--text-muted)',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
transition: 'color var(--duration-fast) var(--easing-standard)',
|
||||||
|
WebkitTapHighlightColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={item.icon} style={{ fontSize: 20 }} />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user