From 1647871a39e25b15f6fcc23ffb87747864800045 Mon Sep 17 00:00:00 2001 From: julian Date: Sat, 30 May 2026 14:08:56 +0000 Subject: [PATCH] feat(web+api): redesign ClientsPage/OrdersPage e corrige dados empresa 9001 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web — ClientsPage: - Redesign completo: métricas reais via usePortfolioStats (4 queries count), donut Chart.js com totais reais, tabela sem ellipsis, coluna Cliente com nome fantasia/razão/CNPJ completos, drawer de detalhes e análise comercial, cards mobile, filtros de status/busca em tempo real. - Dados reais: substitui mock por useClientList/useClientDetail/useClientOrders; remove tipos fictícios (prospect/lead, cidade, totalComprado). Web — OrdersPage: - Métricas reais via useOrderStats (contagens por situa, não da página atual). - Coluna Cliente sem truncamento (minWidth: 240). - Cabeçalho, filtros e layout alinhados ao padrão da ClientsPage. API — orders.service.ts: - Normalização situa SIG→SAR: SIG usa 5=Cancelado; SAR usa 3=Cancelado. sigToSar(5→3) no mapper; sarToSig(3→5) no filtro SQL. API — clients.service.ts: - dt_ultima_compra corrigida: JOIN duplo (vw_pedidos_erp + sar.pedidos) com GREATEST() — clientes com histórico ERP mas sem pedido SAR deixam de aparecer todos como Inativo. - Filtro de activityStatus movido para SQL — total e paginação corretos. - findOne() atualizado com o mesmo JOIN duplo. Infra — .env: - DEV_EMPRESA_ID: 1 → 9001 — API aponta para dados reais da empresa SIG. Ex: pedido nº 141022 passa de R$1.765,48 para R$2.454,90. Docs — sarweb_views.sql: - Documenta as views reais em schema sar; remove schema sarweb inexistente. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- ...edidos-valores-vinculacao-investigation.md | 206 ++ apps/api/src/app/clients/clients.service.ts | 128 +- apps/api/src/app/orders/orders.service.ts | 28 +- apps/web/src/cockpits/rep/ClientsPage.tsx | 1675 +++++++++++++++-- apps/web/src/cockpits/rep/OrdersPage.tsx | 396 ++-- sarweb_views.sql | 605 +----- 6 files changed, 2176 insertions(+), 862 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/investigations/pedidos-valores-vinculacao-investigation.md diff --git a/_bmad-output/implementation-artifacts/investigations/pedidos-valores-vinculacao-investigation.md b/_bmad-output/implementation-artifacts/investigations/pedidos-valores-vinculacao-investigation.md new file mode 100644 index 0000000..0f52fe1 --- /dev/null +++ b/_bmad-output/implementation-artifacts/investigations/pedidos-valores-vinculacao-investigation.md @@ -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`. diff --git a/apps/api/src/app/clients/clients.service.ts b/apps/api/src/app/clients/clients.service.ts index 2dfe343..2d882e6 100644 --- a/apps/api/src/app/clients/clients.service.ts +++ b/apps/api/src/app/clients/clients.service.ts @@ -13,6 +13,7 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types'; 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); @@ -51,6 +52,34 @@ interface ClientRow { 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). +const PEDIDOS_JOINS = ` + LEFT JOIN ( + SELECT id_cliente, id_empresa, MAX(dt_pedido) AS dt_max + FROM vw_pedidos_erp + WHERE situa NOT IN (5) + GROUP BY id_cliente, id_empresa + ) erp_ped ON erp_ped.id_cliente = c.id_cliente AND erp_ped.id_empresa = c.id_empresa + LEFT JOIN ( + SELECT id_cliente, id_empresa, MAX(dt_pedido) AS dt_max + FROM pedidos + WHERE situa != 3 + GROUP BY id_cliente, id_empresa + ) sar_ped ON sar_ped.id_cliente = c.id_cliente AND sar_ped.id_empresa = c.id_empresa +`; + +// 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) {} @@ -72,56 +101,59 @@ export class ClientsService { ? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')` : ''; - const rows = await prisma.$queryRawUnsafe(` - SELECT - c.id_cliente, - c.id_empresa, - c.nome, - c.razao, - c.cgcpf, - c.email, - c.telefone, - c.cod_vendedor, - 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, - MAX(p.dt_pedido) AS dt_ultima_compra - FROM vw_clientes c - LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa AND p.situa != 3 - WHERE c.id_empresa = ${idEmpresa} - AND c.ativo = 1 - ${vendedorFilter} - ${searchFilter} - GROUP BY - c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email, - c.telefone, c.cod_vendedor, c.limite_credito, 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, c.dt_atual - ORDER BY c.nome - LIMIT ${limit} OFFSET ${offset} - `); + // Filtro de status calculado em SQL — evita paginação quebrada do filtro pós-SQL + const statusFilter = status ? `AND ${ACTIVITY_CASE()} = '${status}'` : ''; - const totalRows = await prisma.$queryRawUnsafe<[{ count: string }]>(` - SELECT COUNT(*)::text AS count - FROM vw_clientes c + const baseWhere = ` WHERE c.id_empresa = ${idEmpresa} AND c.ativo = 1 ${vendedorFilter} ${searchFilter} - `); + ${statusFilter} + `; + + const [rows, totalRows] = await Promise.all([ + prisma.$queryRawUnsafe(` + SELECT + c.id_cliente, + c.id_empresa, + c.nome, + c.razao, + c.cgcpf, + c.email, + c.telefone, + c.cod_vendedor, + 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 + ${PEDIDOS_JOINS} + ${baseWhere} + ORDER BY c.nome + LIMIT ${limit} OFFSET ${offset} + `), + prisma.$queryRawUnsafe<[{ count: string }]>(` + SELECT COUNT(*)::text AS count + FROM vw_clientes c + ${PEDIDOS_JOINS} + ${baseWhere} + `), + ]); + const total = parseInt(totalRows[0]?.count ?? '0', 10); - let mapped: ClientSummary[] = rows.map((r) => ({ + const mapped: ClientSummary[] = rows.map((r) => ({ idCliente: Number(r.id_cliente), idEmpresa: Number(r.id_empresa), nome: r.nome, @@ -135,8 +167,6 @@ export class ClientsService { dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null, })); - if (status) mapped = mapped.filter((c) => c.activityStatus === status); - return { data: mapped, total, page, limit }; } @@ -152,14 +182,10 @@ export class ClientsService { 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, - MAX(p.dt_pedido) AS dt_ultima_compra + GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra FROM vw_clientes c - LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa AND p.situa != 3 + ${PEDIDOS_JOINS} WHERE c.id_empresa = ${idEmpresa} AND c.id_cliente = ${idCliente} - GROUP BY c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email, - c.telefone, c.cod_vendedor, c.limite_credito, 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, c.dt_atual LIMIT 1 `); diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index ae25b6a..f3cf5be 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -13,11 +13,23 @@ import type { import type { WorkspaceClsStore } from '../workspace/workspace.types'; import { NotificationsService } from '../notifications/notifications.service'; -// Situa: 1=Pendente Aprovação, 2=Aprovado, 3=Cancelado, 4=Faturado +// Situa SAR: 1=Ag.Aprovação, 2=Aprovado, 3=Cancelado, 4=Faturado +// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado const SITUA_PENDENTE = 1; const SITUA_APROVADO = 2; const SITUA_CANCELADO = 3; +// Mapeia situa SIG → situa SAR para exibição correta no frontend. +// SIG usa 5 para Cancelado; SAR usa 3. Demais valores coincidem. +function sigToSar(sigSitua: number): number { + return sigSitua === 5 ? 3 : sigSitua; +} + +// Mapeia situa SAR → situa SIG para usar nos filtros SQL contra vw_pedidos_erp. +function sarToSig(sarSitua: number): number { + return sarSitua === 3 ? 5 : sarSitua; +} + function decimalToString(v: Prisma.Decimal | null | undefined): string { return v ? v.toString() : '0'; } @@ -40,9 +52,14 @@ export class OrdersService { const { idCliente, situa, numPedSar, from, to, page, limit } = query; const offset = (page - 1) * limit; + // Filtro de vendedor: rep vê apenas seus pedidos const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : ''; const clienteFilter = idCliente != null ? `AND e.id_cliente = ${idCliente}` : ''; - const situaFilter = situa != null ? `AND e.situa = ${situa}` : ''; + + // Converte situa SAR → SIG para filtrar corretamente contra vw_pedidos_erp + const sigSitua = situa != null ? sarToSig(situa) : null; + const situaFilter = sigSitua != null ? `AND e.situa = ${sigSitua}` : ''; + const pedSarFilter = numPedSar ? `AND TRIM(e.num_ped_sar) ILIKE '%${numPedSar}%'` : ''; const fromFilter = from ? `AND e.dt_pedido >= '${from}'` : ''; const toFilter = to ? `AND e.dt_pedido <= '${to}'` : ''; @@ -95,7 +112,8 @@ export class OrdersService { nomeCliente: o.nome_cliente ?? null, razaoCliente: o.razao_cliente ?? null, codVendedor: Number(o.cod_vendedor), - situa: Number(o.situa), + // Normaliza situa SIG → SAR para consistência com pedidos SAR + situa: sigToSar(Number(o.situa)), statusDescr: o.status_descr, dtPedido: new Date(o.dt_pedido).toISOString(), total: o.total ?? '0', @@ -159,10 +177,8 @@ export class OrdersService { const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)])); const getLimit = (codGrupo: number) => limitMap.get(codGrupo) ?? limitMap.get(0) ?? 5; - // Alçada global (codGrupo=0) const needsApproval = dto.descontoPerc > getLimit(0); - // Calcula totais dos itens const itemsData = dto.itens.map((it) => { const descontoValor = Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100; @@ -241,7 +257,6 @@ export class OrdersService { return this.mapDetail(pedido); } - // Aprova pedido pendente. Supervisor pode ajustar descontoPerc global. async approve(id: string, dto: AprovarPedido): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); @@ -300,7 +315,6 @@ export class OrdersService { return this.mapDetail(final); } - // Recusa pedido — muda situa para 3 (Cancelado) com motivo. async reject(id: string, dto: RecusarPedido): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); diff --git a/apps/web/src/cockpits/rep/ClientsPage.tsx b/apps/web/src/cockpits/rep/ClientsPage.tsx index 280f250..5b764f5 100644 --- a/apps/web/src/cockpits/rep/ClientsPage.tsx +++ b/apps/web/src/cockpits/rep/ClientsPage.tsx @@ -1,169 +1,1570 @@ -import { useState } from 'react'; -import { Badge, Input, Select, Space, Table, Typography } from 'antd'; +import { useState, useMemo } from 'react'; +import { + App, + Button, + Card, + Col, + Drawer, + Dropdown, + Grid, + Row, + Select, + Space, + Spin, + Table, + Tag, + Typography, + Divider, +} from 'antd'; import type { TableColumnsType } from 'antd'; +import type { MenuProps } from 'antd'; +import { + BarChartOutlined, + CalendarOutlined, + ClearOutlined, + DollarOutlined, + EllipsisOutlined, + EyeOutlined, + ImportOutlined, + MailOutlined, + PhoneOutlined, + PlusOutlined, + SearchOutlined, + ShoppingCartOutlined, + TeamOutlined, + UserOutlined, + WhatsAppOutlined, +} from '@ant-design/icons'; +import { Doughnut } from 'react-chartjs-2'; +import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from 'chart.js'; import { useNavigate } from '@tanstack/react-router'; import type { ActivityStatus, ClientSummary } from '@sar/api-interface'; -import { useClientList } from '../../lib/queries/clients'; +import { SITUA_LABEL } from '@sar/api-interface'; +import { useClientList, useClientDetail } from '../../lib/queries/clients'; +import { useClientOrders } from '../../lib/queries/orders'; -const { Title } = Typography; -const { Search } = Input; +ChartJS.register(ArcElement, ChartTooltip, Legend); -// ─── Badge configs ──────────────────────────────────────────────────────────── +const { Title, Text } = Typography; +const { useBreakpoint } = Grid; -const ACTIVITY_CONFIG: Record = { - active: { color: 'success', label: 'Ativo' }, - alert: { color: 'warning', label: 'Em alerta' }, - inactive: { color: 'error', label: 'Inativo' }, +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function fmt(v: string | number | null | undefined): string { + if (v == null || v === '') return '—'; + const n = typeof v === 'string' ? parseFloat(v) : v; + if (isNaN(n) || n === 0) return '—'; + return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); +} + +function fmtDate(v: string | null | undefined): string { + if (!v) return '—'; + return new Date(v).toLocaleDateString('pt-BR'); +} + +function diasSemComprar(dt: string | null | undefined): number | null { + if (!dt) return null; + return Math.floor((Date.now() - new Date(dt).getTime()) / 86_400_000); +} + +function getSugestao(status: ActivityStatus, dt: string | null | undefined): string { + const dias = diasSemComprar(dt); + if (status === 'active') { + if (!dias || dias < 30) + return 'Cliente ativo com compra recente. Mantenha o relacionamento e explore oportunidades de upsell.'; + return `Cliente ativo há ${dias} dias sem comprar. Reforce o contato e apresente novidades.`; + } + if (status === 'alert') { + return `Cliente em alerta há ${dias ?? '—'} dias sem comprar. Priorize contato comercial para reativação.`; + } + if (!dias) return 'Cliente inativo. Sem histórico de compra disponível.'; + return `Cliente inativo há ${dias} dias. Situação crítica — ofereça condições especiais para reativação.`; +} + +// ─── Status Config ──────────────────────────────────────────────────────────── + +const ACTIVITY_CFG: Record = { + active: { label: 'Ativo', tagColor: 'success', color: '#389e0d' }, + alert: { label: 'Em alerta', tagColor: 'warning', color: '#d46b08' }, + inactive: { label: 'Inativo', tagColor: 'error', color: '#cf1322' }, }; -// ─── Columns ────────────────────────────────────────────────────────────────── +// ─── CustomerStatusBadge ────────────────────────────────────────────────────── -function buildColumns(navigate: ReturnType): TableColumnsType { - return [ - { - title: 'Cliente', - dataIndex: 'nome', - key: 'nome', - render: (nome: string, record: ClientSummary) => ( - - + {cfg.label} + + ); +} + +// ─── usePortfolioStats ──────────────────────────────────────────────────────── + +function usePortfolioStats() { + const all = useClientList({ limit: 1 }); + const active = useClientList({ limit: 1, status: 'active' }); + const alertQ = useClientList({ limit: 1, status: 'alert' }); + const inactive = useClientList({ limit: 1, status: 'inactive' }); + return { + total: all.data?.total ?? 0, + ativos: active.data?.total ?? 0, + emAlerta: alertQ.data?.total ?? 0, + inativos: inactive.data?.total ?? 0, + loaded: !!all.data, + }; +} + +type PortfolioStats = ReturnType; + +// ─── CustomerMetrics ────────────────────────────────────────────────────────── + +function CustomerMetrics({ stats }: { stats: PortfolioStats }) { + const metrics = [ + { label: 'Total de Clientes', value: stats.total, icon: , color: '#003B8E' }, + { label: 'Ativos', value: stats.ativos, icon: , color: '#389e0d' }, + { label: 'Em alerta', value: stats.emAlerta, icon: , color: '#d46b08' }, + { label: 'Inativos', value: stats.inativos, icon: , color: '#cf1322' }, + ]; + return ( + + {metrics.map((m) => ( + + + +
+ {m.icon} +
+
+ + {m.label} + + + {stats.loaded ? m.value.toLocaleString('pt-BR') : } + +
+
+
+ + ))} +
+ ); +} + +// ─── CustomerPortfolioCard ──────────────────────────────────────────────────── + +function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) { + const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); + const total = stats.ativos + stats.emAlerta + stats.inativos; + + const donutData = { + labels: ['Ativos', 'Em alerta', 'Inativos'], + datasets: [ + { + data: [stats.ativos, stats.emAlerta, stats.inativos], + backgroundColor: ['#52C41A', '#FAAD14', '#FF4D4F'], + borderColor: '#fff', + borderWidth: 3, + hoverOffset: 6, + }, + ], + }; + + const donutOptions = { + responsive: true, + maintainAspectRatio: true, + cutout: '68%', + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (ctx: { label: string; raw: unknown }) => { + const v = ctx.raw as number; + const pct = total > 0 ? ((v / total) * 100).toFixed(1) : '0.0'; + return `${ctx.label}: ${v.toLocaleString('pt-BR')} (${pct}%)`; + }, + }, + }, + }, + }; + + const legendItems = [ + { label: 'Ativos', value: stats.ativos, color: '#52C41A' }, + { label: 'Em alerta', value: stats.emAlerta, color: '#FAAD14' }, + { label: 'Inativos', value: stats.inativos, color: '#FF4D4F' }, + ]; + + return ( + +
+ + Carteira de Clientes + + + {mesAtual} + +
+ +
+ {stats.loaded && total > 0 ? ( + <> + +
+ + {stats.total.toLocaleString('pt-BR')} + + Clientes +
+ + ) : ( +
+ +
+ )} +
+ + + {legendItems.map((item) => { + const pct = total > 0 ? ((item.value / total) * 100).toFixed(1) : '0.0'; + return ( +
+ +
+ {item.label} + + + + {item.value.toLocaleString('pt-BR')} + + + {pct}% + + +
+ ); + })} +
+ + + + + ); +} + +// ─── CustomerExpandedDetail ─────────────────────────────────────────────────── + +function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) { + const { data: detail, isLoading } = useClientDetail(summary.idCliente); + + const label: React.CSSProperties = { + fontSize: 11, + fontWeight: 700, + letterSpacing: '0.07em', + textTransform: 'uppercase', + color: '#94A3B8', + display: 'block', + marginBottom: 2, + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const d = detail; + const phone = d + ? d.ddd + ? `(${d.ddd}) ${d.telefone ?? ''}`.trim() + : (d.telefone ?? '—') + : (summary.telefone ?? '—'); + const endereco = d + ? [d.endereco, d.numEndereco, d.bairro, d.cep ? `CEP ${d.cep}` : ''] + .filter(Boolean) + .join(', ') || '—' + : '—'; + const limiteFormatado = fmt(d?.limiteCreditoStr ?? summary.limiteCreditoStr); + + return ( +
+ + {endereco !== '—' && ( + + Endereço + {endereco} + + )} + + Telefone + {phone} + + {d?.inscricaoEstadual && ( + + Insc. Estadual + {d.inscricaoEstadual} + + )} + {limiteFormatado !== '—' && ( + + Limite de Crédito + + {limiteFormatado} + + + )} + {d?.obs && ( + + Observações + {d.obs} + + )} + +
+ ); +} + +// ─── CustomerDetailsDrawer ──────────────────────────────────────────────────── + +function CustomerDetailsDrawer({ + summary, + onClose, + onAnalyze, +}: { + summary: ClientSummary | null; + onClose: () => void; + onAnalyze: () => void; +}) { + const navigate = useNavigate(); + const { data: detail, isLoading: loadingDetail } = useClientDetail(summary?.idCliente); + const { data: orders = [], isLoading: loadingOrders } = useClientOrders(summary?.idCliente); + + if (!summary) return null; + + const d = detail; + const phone = d + ? d.ddd + ? `(${d.ddd}) ${d.telefone ?? ''}`.trim() + : (d.telefone ?? '—') + : (summary.telefone ?? '—'); + const endereco = d + ? [d.endereco, d.numEndereco, d.bairro, d.cep ? `CEP ${d.cep}` : ''] + .filter(Boolean) + .join(', ') || '—' + : '—'; + const limiteFormatado = fmt(d?.limiteCreditoStr ?? summary.limiteCreditoStr); + const dias = diasSemComprar(summary.dtUltimaCompra); + + const label: React.CSSProperties = { + fontSize: 11, + fontWeight: 700, + letterSpacing: '0.07em', + textTransform: 'uppercase', + color: '#94A3B8', + display: 'block', + marginBottom: 2, + }; + + return ( + + + {summary.nome} + + + + } + open + onClose={onClose} + width={520} + placement="right" + styles={{ body: { padding: '16px 24px' } }} + footer={ + + + + - ), - sorter: true, + } + > + + {/* Identificação */} + + + {summary.nome} + + {summary.razao && ( + + {summary.razao} + + )} + {summary.cgcpf && ( + + {summary.cgcpf} + + )} + + + {/* Dados cadastrais */} +
+ + Dados Cadastrais + + + + Telefone + + + {phone} + + + + E-mail + + + {summary.email ?? '—'} + + + {loadingDetail ? ( + + + + ) : ( + <> + {endereco !== '—' && ( + + Endereço + {endereco} + + )} + {d?.inscricaoEstadual && ( + + Insc. Estadual + {d.inscricaoEstadual} + + )} + + )} + +
+ + + + {/* Dados comerciais */} +
+ + Dados Comerciais + + + {limiteFormatado !== '—' && ( + + Limite de Crédito + + {limiteFormatado} + + + )} + + Cód. Vendedor + {summary.codVendedor} + + {summary.dtUltimaCompra && ( + + Última Compra + + + + {fmtDate(summary.dtUltimaCompra)} + {dias !== null && ( + + ({dias} dias) + + )} + + + + )} + +
+ + {/* Últimos pedidos */} + {(loadingOrders || orders.length > 0) && ( + <> + +
+ + Últimos Pedidos + + {loadingOrders ? ( + + ) : ( + + {orders.slice(0, 5).map((p) => ( +
+ + + {p.numPedSar} + + + {new Date(p.dtPedido).toLocaleDateString('pt-BR')} + + + + + {SITUA_LABEL[p.situa] ?? String(p.situa)} + + + {Number(p.total).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + })} + + +
+ ))} +
+ )} +
+ + )} + + {/* Observações */} + {d?.obs && ( + <> + +
+ Observações +
+ {d.obs} +
+
+ + )} + + +
+
+ ); +} + +// ─── CustomerAnalysisDrawer ─────────────────────────────────────────────────── + +function CustomerAnalysisDrawer({ + summary, + onClose, +}: { + summary: ClientSummary | null; + onClose: () => void; +}) { + const { data: detail } = useClientDetail(summary?.idCliente); + + if (!summary) return null; + + const dias = diasSemComprar(summary.dtUltimaCompra); + const sugestao = getSugestao(summary.activityStatus, summary.dtUltimaCompra); + const urgencyColor = ACTIVITY_CFG[summary.activityStatus].color; + const limiteFormatado = fmt(detail?.limiteCreditoStr ?? summary.limiteCreditoStr); + + const label: React.CSSProperties = { + fontSize: 11, + fontWeight: 700, + letterSpacing: '0.07em', + textTransform: 'uppercase', + color: '#94A3B8', + display: 'block', + marginBottom: 2, + }; + + return ( + + + Análise Comercial + + } + open + onClose={onClose} + width={480} + placement="right" + styles={{ body: { padding: '16px 24px' } }} + > + +
+ + {summary.nome} + + + + {summary.cgcpf && ( + {summary.cgcpf} + )} + +
+ + + {[ + { + label: 'Limite de Crédito', + value: limiteFormatado, + icon: , + color: '#003B8E', + }, + { + label: 'Última Compra', + value: fmtDate(summary.dtUltimaCompra), + icon: , + color: '#d46b08', + }, + { + label: 'Dias s/ Comprar', + value: dias !== null ? `${dias} dias` : '—', + icon: , + color: urgencyColor, + }, + { + label: 'Cód. Vendedor', + value: String(summary.codVendedor), + icon: , + color: '#64748B', + }, + ].map((m) => ( + + + + {m.icon} +
+ {m.label} + + {m.value} + +
+
+
+ + ))} +
+ + + +
+ + Sugestão de Ação Comercial + +
+ + {sugestao} + +
+
+ + {detail?.obs && ( +
+ Observações +
+ + {detail.obs} + +
+
+ )} + + + + + + +
+
+ ); +} + +// ─── CustomerActionsMenu ────────────────────────────────────────────────────── + +function CustomerActionsMenu({ + summary, + onView, + onAnalyze, +}: { + summary: ClientSummary; + onView: () => void; + onAnalyze: () => void; +}) { + const navigate = useNavigate(); + const items: MenuProps['items'] = [ + { key: 'view', icon: , label: 'Ver detalhes', onClick: onView }, + { key: 'analyze', icon: , label: 'Analisar', onClick: onAnalyze }, + { type: 'divider' }, + { + key: 'orders', + icon: , + label: 'Ver pedidos', + onClick: () => void navigate({ to: '/pedidos' }), }, { - title: 'CNPJ / CPF', - dataIndex: 'cgcpf', - key: 'cgcpf', - width: 160, - render: (v: string | null) => ( - - {v ?? '—'} - + key: 'new-order', + icon: , + label: Criar pedido, + onClick: () => + void navigate({ to: '/pedidos/novo', search: { clientId: String(summary.idCliente) } }), + }, + { type: 'divider' }, + { + key: 'whatsapp', + icon: , + label: 'Enviar WhatsApp', + onClick: () => + window.open(`https://wa.me/55${(summary.telefone ?? '').replace(/\D/g, '')}`, '_blank'), + }, + { + key: 'email', + icon: , + label: 'Enviar e-mail', + onClick: () => window.open(`mailto:${summary.email ?? ''}`, '_blank'), + }, + ]; + return ( + + + + + + + ); +} + +// ─── ClientsPage ────────────────────────────────────────────────────────────── + +export function ClientsPage() { + const screens = useBreakpoint(); + const isMobile = !screens.md; + const { message: msg } = App.useApp(); + + const stats = usePortfolioStats(); + + const [search, setSearch] = useState(''); + const [query, setQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState(); + const [sortBy, setSortBy] = useState('nome_az'); + const [page, setPage] = useState(1); + const limit = 50; + + const [detailSummary, setDetailSummary] = useState(null); + const [analysisSummary, setAnalysisSummary] = useState(null); + + const { data, isLoading, isFetching } = useClientList({ + q: query || undefined, + status: statusFilter, + page, + limit, + }); + + const rows = data?.data ?? []; + + const sorted = useMemo(() => { + const r = [...rows]; + if (sortBy === 'nome_az') r.sort((a, b) => a.nome.localeCompare(b.nome, 'pt-BR')); + if (sortBy === 'nome_za') r.sort((a, b) => b.nome.localeCompare(a.nome, 'pt-BR')); + if (sortBy === 'ultima_compra') { + r.sort((a, b) => { + const da = a.dtUltimaCompra ? new Date(a.dtUltimaCompra).getTime() : 0; + const db = b.dtUltimaCompra ? new Date(b.dtUltimaCompra).getTime() : 0; + return db - da; + }); + } + return r; + }, [rows, sortBy]); + + const hasFilters = !!query || !!statusFilter || sortBy !== 'nome_az'; + + function commitSearch() { + setQuery(search.trim()); + setPage(1); + } + function clearFilters() { + setSearch(''); + setQuery(''); + setStatusFilter(undefined); + setSortBy('nome_az'); + setPage(1); + } + + const expandable = { + expandedRowRender: (s: ClientSummary) => , + rowExpandable: () => true, + }; + + const columns: TableColumnsType = [ + { + title: 'Cliente', + key: 'cliente', + minWidth: 280, + render: (_: unknown, c: ClientSummary) => ( +
+ setDetailSummary(c)} + > + {c.nome} + + {c.razao && ( + + {c.razao} + + )} + {c.cgcpf && ( + + {c.cgcpf} + + )} +
), }, { - title: 'Atividade', + title: 'Status', dataIndex: 'activityStatus', - key: 'activityStatus', + key: 'status', width: 120, - render: (v: ActivityStatus) => { - const cfg = ACTIVITY_CONFIG[v]; - return ; + render: (s: ActivityStatus) => , + }, + { + title: 'Contato', + key: 'contato', + width: 195, + render: (_: unknown, c: ClientSummary) => ( +
+ {c.telefone && ( + + + {c.telefone} + + )} + {c.email && ( + <> + {c.telefone &&
} + + + + {c.email} + + + + )} + {!c.telefone && !c.email && ( + + — + + )} +
+ ), + }, + { + title: 'Limite de Crédito', + dataIndex: 'limiteCreditoStr', + key: 'limiteCredito', + width: 130, + align: 'right' as const, + render: (v: string | null) => { + const f = fmt(v); + return f !== '—' ? ( + + {f} + + ) : ( + + — + + ); }, }, { title: 'Última compra', dataIndex: 'dtUltimaCompra', - key: 'dtUltimaCompra', - width: 140, - render: (v: string | null) => { - if (!v) return ; - return ( - - {new Date(v).toLocaleDateString('pt-BR')} - - ); - }, + key: 'ultimaCompra', + width: 118, + render: (v: string | null) => + v ? ( +
+ + {fmtDate(v)} + + {diasSemComprar(v)} dias atrás +
+ ) : ( + + Sem compras + + ), + }, + { + title: 'Ações', + key: 'actions', + width: 120, + render: (_: unknown, c: ClientSummary) => ( + + + )} +
+ + {/* ── Métricas ──────────────────────────────────────────────────── */} + + + {/* ── Barra de ações ────────────────────────────────────────────── */} + + + + + + + + + + +
+ + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitSearch(); + }} + onBlur={commitSearch} + placeholder="Pesquisar por nome, razão social ou CNPJ..." + style={{ + width: '100%', + height: 34, + padding: '0 11px 0 32px', + border: '1px solid #d9d9d9', + borderRadius: 8, + fontSize: 13, + outline: 'none', + color: '#1F2937', + boxSizing: 'border-box', + }} + /> +
+ +
+
+ + {/* ── Filtros ───────────────────────────────────────────────────── */} + + + + + + + + + + + {data?.total !== undefined ? `${data.total.toLocaleString('pt-BR')} clientes` : '…'} + + + + + + {/* ── Portfolio mobile (antes da lista) ─────────────────────────── */} + {isMobile && ( + <> + +
+ + )} + + {/* ── Área principal ────────────────────────────────────────────── */} + + + {isLoading ? ( +
+ +
+ ) : sorted.length === 0 ? ( + +
+ + + Nenhum cliente encontrado + + Tente ajustar os filtros ou a busca. +
+
+ ) : isMobile ? ( +
+ {sorted.map((c) => ( + setDetailSummary(c)} + /> + ))} + + Mostrando {sorted.length} de {data?.total ?? 0} clientes + +
+ ) : ( + + + rowKey="idCliente" + columns={columns} + dataSource={sorted} + expandable={expandable} + size="middle" + loading={isFetching} + scroll={{ x: 1000 }} + pagination={{ + current: page, + pageSize: limit, + total: data?.total ?? 0, + showSizeChanger: false, + showTotal: (t, [s, e]) => `Mostrando ${s}–${e} de ${t} clientes`, + onChange: (p) => setPage(p), + style: { padding: '12px 24px' }, + }} + onRow={() => ({ style: { cursor: 'default', verticalAlign: 'top' } })} + style={{ borderRadius: 10, overflow: 'hidden' }} + /> + + )} + + + {!isMobile && ( + + + + )} +
+ + {/* ── Drawers ───────────────────────────────────────────────────── */} + {detailSummary && ( + setDetailSummary(null)} + onAnalyze={() => { + setAnalysisSummary(detailSummary); + setDetailSummary(null); + }} + /> + )} + {analysisSummary && ( + setAnalysisSummary(null)} + /> + )} + + {/* FAB mobile */} + {isMobile && ( +
); } diff --git a/apps/web/src/cockpits/rep/OrdersPage.tsx b/apps/web/src/cockpits/rep/OrdersPage.tsx index 7697e2e..21a89c9 100644 --- a/apps/web/src/cockpits/rep/OrdersPage.tsx +++ b/apps/web/src/cockpits/rep/OrdersPage.tsx @@ -1,11 +1,11 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { + App, Button, Card, Col, Drawer, Dropdown, - Empty, Grid, Row, Select, @@ -15,6 +15,7 @@ import { Tag, Timeline, Typography, + Divider, } from 'antd'; import type { TableColumnsType } from 'antd'; import type { MenuProps } from 'antd'; @@ -29,6 +30,8 @@ import { FilePdfOutlined, PlusOutlined, ShoppingCartOutlined, + ClearOutlined, + SearchOutlined, } from '@ant-design/icons'; import { Link, useNavigate } from '@tanstack/react-router'; import type { PedidoSummary } from '@sar/api-interface'; @@ -68,7 +71,7 @@ function periodRange(p: string): { from?: string; to?: string } { return {}; } -// ─── Status ─────────────────────────────────────────────────────────────────── +// ─── Status Config ──────────────────────────────────────────────────────────── const STATUS: Record = { 1: { label: 'Ag. Aprovação', color: '#d46b08', rowBg: '#fffbe6', tagColor: 'orange' }, @@ -77,66 +80,73 @@ const STATUS: Record - {label} - - ); +function useOrderStats() { + const all = useOrderList({ limit: 1 }); + const pendentes = useOrderList({ limit: 1, situa: 1 }); + const aprovados = useOrderList({ limit: 1, situa: 2 }); + const faturados = useOrderList({ limit: 1, situa: 4 }); + return { + total: all.data?.total ?? 0, + pendentes: pendentes.data?.total ?? 0, + aprovados: aprovados.data?.total ?? 0, + faturados: faturados.data?.total ?? 0, + loaded: !!all.data, + }; } +type OrderStats = ReturnType; + // ─── OrdersMetrics ──────────────────────────────────────────────────────────── -function OrdersMetrics({ data }: { data: PedidoSummary[] }) { - const total = data.reduce((a, o) => a + Number(o.total), 0); - const pendentes = data.filter((o) => o.situa === 1).length; - const aprovados = data.filter((o) => o.situa === 2).length; - const ticket = data.length > 0 ? total / data.length : 0; - +function OrdersMetrics({ stats }: { stats: OrderStats }) { const metrics = [ { label: 'Total de Pedidos', - value: String(data.length), + value: stats.total, icon: , color: '#003B8E', }, - { label: 'Total Vendido', value: fmt(total), icon: , color: '#389e0d' }, { label: 'Ag. Aprovação', - value: String(pendentes), + value: stats.pendentes, icon: , color: '#d46b08', }, - { - label: 'Aprovados', - value: String(aprovados), - icon: , - color: '#389e0d', - }, - { label: 'Ticket Médio', value: fmt(ticket), icon: , color: '#1d39c4' }, + { label: 'Aprovados', value: stats.aprovados, icon: , color: '#389e0d' }, + { label: 'Faturados', value: stats.faturados, icon: , color: '#1d39c4' }, ]; return ( - + {metrics.map((m) => ( - + - {m.icon} +
+ {m.icon} +
{m.label} - - {m.value} + + {stats.loaded ? m.value.toLocaleString('pt-BR') : }
@@ -162,6 +172,21 @@ function OrdersMetrics({ data }: { data: PedidoSummary[] }) { ); } +// ─── OrderStatusBadge ───────────────────────────────────────────────────────── + +function OrderStatusBadge({ situa, descr }: { situa: number; descr?: string }) { + const cfg = STATUS[situa]; + const label = descr ?? cfg?.label ?? SITUA_LABEL[situa] ?? String(situa); + return ( + + {label} + + ); +} + // ─── OrderActionsMenu ───────────────────────────────────────────────────────── function OrderActionsMenu({ @@ -250,7 +275,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', - color: '#64748B', + color: '#94A3B8', marginBottom: 2, display: 'block', }; @@ -261,6 +286,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => open={!!id} onClose={onClose} width={520} + placement="right" styles={{ body: { padding: '16px 24px' } }} footer={ @@ -296,13 +322,13 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => Data {fmtDate(data.dtPedido)} - + Cliente - + {data.razaoCliente ?? data.nomeCliente ?? `Cód. ${data.idCliente}`} {data.nomeCliente && data.razaoCliente && ( - + {data.nomeCliente} )} @@ -313,6 +339,12 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => {fmt(data.total)} + {data.descontoPerc && Number(data.descontoPerc) > 0 && ( + + Desconto + {Number(data.descontoPerc).toLocaleString('pt-BR')}% + + )} {data.obs && ( Observações @@ -394,6 +426,7 @@ function MobileOrderCard({ }) { const navigate = useNavigate(); const cfg = STATUS[order.situa]; + const nome = order.razaoCliente ?? order.nomeCliente; return ( -
- +
+ {order.numPedSar}
- - {order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`} ·{' '} + {nome && ( + + {nome} + + )} + {fmtDate(order.dtPedido)} @@ -425,12 +462,14 @@ function MobileOrderCard({ icon={} disabled={order.fonte === 'erp'} onClick={() => onView(order.id)} + style={{ borderRadius: 6 }} > Ver - - ); -} - // ─── OrdersPage ─────────────────────────────────────────────────────────────── export function OrdersPage() { const navigate = useNavigate(); const screens = useBreakpoint(); const isMobile = !screens.md; + const { message: msg } = App.useApp(); + + const stats = useOrderStats(); const [search, setSearch] = useState(''); + const [query, setQuery] = useState(''); const [situaFilter, setSituaFilter] = useState(); const [period, setPeriod] = useState(''); const [page, setPage] = useState(1); @@ -482,8 +501,8 @@ export function OrdersPage() { const { from, to } = period ? periodRange(period) : {}; - const { data, isLoading } = useOrderList({ - numPedSar: search || undefined, + const { data, isLoading, isFetching } = useOrderList({ + numPedSar: query || undefined, situa: situaFilter, from, to, @@ -494,28 +513,39 @@ export function OrdersPage() { const rows = data?.data ?? []; const total = data?.total ?? 0; + const hasFilters = !!query || !!situaFilter || !!period; + + function commitSearch() { + setQuery(search.trim()); + setPage(1); + } + function clearFilters() { setSearch(''); + setQuery(''); setSituaFilter(undefined); setPeriod(''); setPage(1); } - // ── Tabela desktop ───────────────────────────────────────────────────────── + // Valor total da página atual (sem query separada) + const valorPagina = useMemo(() => rows.reduce((a, o) => a + Number(o.total), 0), [rows]); + + // ── Colunas desktop ───────────────────────────────────────────────────────── const columns: TableColumnsType = [ { - title: 'Nº Pedido', - dataIndex: 'numPedSar', + title: 'Pedido', + key: 'pedido', width: 140, - render: (_: string, row: PedidoSummary) => { + render: (_: unknown, row: PedidoSummary) => { const label = row.numero ? String(row.numero) : row.numPedSar; return row.fonte === 'erp' ? ( - + {label} ) : ( - + {label} @@ -525,28 +555,37 @@ export function OrdersPage() { { title: 'Cliente', key: 'cliente', - ellipsis: true, + minWidth: 240, render: (_: unknown, row: PedidoSummary) => { const nome = row.razaoCliente ?? row.nomeCliente; + const subtit = row.nomeCliente && row.razaoCliente ? row.nomeCliente : null; return ( - +
{nome ? ( - {nome} + + {nome} + ) : ( - Cód. {row.idCliente} - )} - {row.nomeCliente && row.razaoCliente && ( - - {row.nomeCliente} + + Cód. {row.idCliente} )} - + {subtit && ( + + {subtit} + + )} +
); }, }, { title: 'Status', dataIndex: 'situa', + key: 'status', width: 140, render: (s: number, row: PedidoSummary) => ( @@ -555,10 +594,11 @@ export function OrdersPage() { { title: 'Total', dataIndex: 'total', + key: 'total', width: 130, - align: 'right', + align: 'right' as const, render: (v: string) => ( - + {fmt(v)} ), @@ -566,22 +606,34 @@ export function OrdersPage() { { title: 'Data', dataIndex: 'dtPedido', + key: 'dtPedido', width: 110, - render: (v: string) => {fmtDate(v)}, + render: (v: string) => {fmtDate(v)}, }, { title: '', key: 'actions', - width: 48, + width: 100, render: (_: unknown, row: PedidoSummary) => ( - setDrawerOrderId(id)} /> + + )}
- {/* ── Métricas ────────────────────────────────────────────────── */} - + {/* ── Métricas ──────────────────────────────────────────────────── */} + - {/* ── Filtros ─────────────────────────────────────────────────── */} + {/* ── Filtros ───────────────────────────────────────────────────── */} - - { - setSearch(e.target.value); - setPage(1); - }} - placeholder="Buscar por nº do pedido..." - style={{ - width: '100%', - height: 32, - padding: '0 11px', - border: '1px solid #d9d9d9', - borderRadius: 6, - fontSize: 14, - outline: 'none', - color: '#1F2937', - boxSizing: 'border-box', - }} - /> + {/* Busca */} + +
+ + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitSearch(); + }} + onBlur={commitSearch} + placeholder="Buscar por nº do pedido..." + style={{ + width: '100%', + height: 32, + padding: '0 11px 0 32px', + border: '1px solid #d9d9d9', + borderRadius: 6, + fontSize: 13, + outline: 'none', + color: '#1F2937', + boxSizing: 'border-box', + }} + /> +
+ + {/* Status */} - + + {/* Limpar */} + + + {/* Contador */} + + + {data?.total !== undefined ? `${total.toLocaleString('pt-BR')} pedidos` : '…'} + +
- {/* ── Conteúdo principal ──────────────────────────────────────── */} + {/* ── Lista / tabela ────────────────────────────────────────────── */} {isLoading ? (
@@ -703,27 +788,51 @@ export function OrdersPage() { style={{ borderRadius: 10, border: '1px solid #EBF0F5' }} styles={{ body: { padding: 0 } }} > - void navigate({ to: '/pedidos/novo' })} /> +
+ + + Nenhum pedido encontrado + + Tente alterar os filtros ou crie um novo pedido. +
+ +
+
) : isMobile ? ( - /* ── Mobile: cards ─────────────────────────────────────────── */ + /* ── Mobile ────────────────────────────────────────────────────── */
{rows.map((o) => ( setDrawerOrderId(id)} /> ))} -
Mostrando {rows.length} de {total} pedidos -
+
) : ( - /* ── Desktop: tabela ────────────────────────────────────────── */ + /* ── Desktop ────────────────────────────────────────────────────── */ @@ -732,6 +841,8 @@ export function OrdersPage() { columns={columns} dataSource={rows} size="middle" + loading={isFetching} + scroll={{ x: 900 }} onRow={(row) => ({ onClick: () => { if (row.fonte !== 'erp') setDrawerOrderId(row.id); @@ -739,6 +850,7 @@ export function OrdersPage() { style: { background: STATUS[row.situa]?.rowBg ?? '#fff', cursor: row.fonte !== 'erp' ? 'pointer' : 'default', + verticalAlign: 'top', }, })} pagination={{ @@ -751,11 +863,21 @@ export function OrdersPage() { style: { padding: '12px 24px' }, }} style={{ borderRadius: 10, overflow: 'hidden' }} + footer={() => ( +
+ + Valor nesta página:{' '} + + {fmt(valorPagina)} + + +
+ )} />
)} - {/* ── Drawer de detalhe ───────────────────────────────────────── */} + {/* ── Drawer de detalhe ─────────────────────────────────────────── */} setDrawerOrderId(null)} /> {/* FAB mobile */} @@ -784,6 +906,16 @@ export function OrdersPage() { /> )} + {/* Aviso de novo pedido via mensagem se tentativa em mobile */} + {isMobile && ( +