diff --git a/_bmad-output/planning-artifacts/architecture.md b/_bmad-output/planning-artifacts/architecture.md new file mode 100644 index 0000000..6244629 --- /dev/null +++ b/_bmad-output/planning-artifacts/architecture.md @@ -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; + +// Schema de saída (query) +export const XxxResponseSchema = z.object({ ... }); +export type XxxResponse = z.infer; + +// 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 `` ou `` +- 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 ` 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.* diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 00d9114..6191445 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -15,11 +15,11 @@ export class AuthController { 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)} - AND id_empresa = ${idEmpresa} LIMIT 1 `; diff --git a/apps/api/src/app/catalog/catalog.controller.ts b/apps/api/src/app/catalog/catalog.controller.ts index 5dc85ff..73bce34 100644 --- a/apps/api/src/app/catalog/catalog.controller.ts +++ b/apps/api/src/app/catalog/catalog.controller.ts @@ -2,6 +2,8 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from ' import { createZodDto } from 'nestjs-zod'; import { ProdutoListQuerySchema, + type EmpresaInfo, + type FormaPagamento, type Pauta, type ProdutoDetail, type ProdutoListQuery, @@ -22,6 +24,16 @@ export class CatalogController { return this.catalog.pautas(); } + @Get('payment-methods') + formasPagamento(): Promise { + return this.catalog.formasPagamento(); + } + + @Get('company') + company(): Promise { + return this.catalog.company(); + } + @Get() list(@Query() query: ProdutoListQueryDto): Promise { const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery; diff --git a/apps/api/src/app/catalog/catalog.service.ts b/apps/api/src/app/catalog/catalog.service.ts index d48c584..a9b913d 100644 --- a/apps/api/src/app/catalog/catalog.service.ts +++ b/apps/api/src/app/catalog/catalog.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; import type { + EmpresaInfo, + FormaPagamento, Pauta, ProdutoDetail, ProdutoListQuery, @@ -16,6 +18,14 @@ 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; @@ -45,10 +55,77 @@ interface ProdutoRow { export class CatalogService { constructor(private readonly cls: ClsService) {} + // Dados legais da empresa matriz que fatura o pedido (cabeçalho do PDF). + async company(): Promise { + 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(` + SELECT e.id_empresa, + TRIM(e.razao_social) AS razao_social, + TRIM(e.nome) AS nome_fantasia, + TRIM(e.cnpj) AS cnpj, + TRIM(e.inscr_estadual) AS inscr_estadual, + TRIM(e.endereco) AS endereco, + NULLIF(e.numero, 0)::text AS numero, + NULLIF(TRIM(e.complemento), '.') AS complemento, + TRIM(e.bairro) AS bairro, + TRIM(m.nome) AS cidade, + TRIM(e.estado::text) AS uf, + TRIM(e.cep::text) AS cep, + TRIM(e.telefone::text) AS telefone, + TRIM(e.email) AS email + FROM gestao.empresa e + LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio + WHERE e.id_empresa = ${idEmpresa} + LIMIT 1 + `); + const r = rows[0]; + if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`); + + const clean = (v: string | null) => { + const t = (v ?? '').trim(); + return t === '' ? null : t; + }; + return { + idEmpresa: Number(r.id_empresa), + razaoSocial: clean(r.razao_social) ?? clean(r.nome_fantasia) ?? `Empresa ${r.id_empresa}`, + nomeFantasia: clean(r.nome_fantasia), + cnpj: clean(r.cnpj), + inscricaoEstadual: clean(r.inscr_estadual), + endereco: clean(r.endereco), + numero: clean(r.numero), + complemento: clean(r.complemento), + bairro: clean(r.bairro), + cidade: clean(r.cidade), + uf: clean(r.uf), + cep: clean(r.cep), + telefone: clean(r.telefone), + email: clean(r.email), + }; + } + async pautas(): Promise { 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 idEmpresa = matrizEmpresa(this.cls.get('idEmpresa')); const userId = this.cls.get('userId'); const codVendedor = userId ? parseInt(userId, 10) : 0; @@ -60,8 +137,7 @@ export class CatalogService { const rows = await prisma.$queryRawUnsafe(` SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao FROM vw_pautas pa - JOIN vw_representantes r ON r.id_empresa = pa.id_empresa - AND pa.codigo IN ( + 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 ) @@ -81,7 +157,7 @@ export class CatalogService { async list(query: ProdutoListQuery): Promise { 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 idEmpresa = matrizEmpresa(this.cls.get('idEmpresa')); const { q, codGrupo, idPauta, page, limit } = query; const offset = (page - 1) * limit; @@ -183,7 +259,7 @@ export class CatalogService { async findOne(idErp: number): Promise { 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 idEmpresa = matrizEmpresa(this.cls.get('idEmpresa')); const rows = await prisma.$queryRawUnsafe(` SELECT @@ -232,4 +308,33 @@ export class CatalogService { precoPromocional: p.preco_promocional, }; } + + async formasPagamento(): Promise { + 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(` + 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', + })); + } } diff --git a/apps/api/src/app/clients/clients.service.ts b/apps/api/src/app/clients/clients.service.ts index 2d882e6..b2fd730 100644 --- a/apps/api/src/app/clients/clients.service.ts +++ b/apps/api/src/app/clients/clients.service.ts @@ -36,6 +36,7 @@ interface ClientRow { email: string | null; telefone: string | null; cod_vendedor: number; + nome_vendedor: string | null; limite_credito: string | null; dt_ultima_compra: Date | null; ativo: number; @@ -55,20 +56,33 @@ interface ClientRow { // 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 = ` +// 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, id_empresa, MAX(dt_pedido) AS dt_max + SELECT id_cliente, 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 + 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, id_empresa, MAX(dt_pedido) AS dt_max + SELECT id_cliente, 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 -`; + 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') => ` @@ -104,13 +118,14 @@ export class ClientsService { // 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.id_empresa = ${idEmpresa} - AND c.ativo = 1 + WHERE c.ativo = 1 ${vendedorFilter} ${searchFilter} ${statusFilter} `; + const joins = pedidosJoins(idEmpresa); const [rows, totalRows] = await Promise.all([ prisma.$queryRawUnsafe(` @@ -123,6 +138,7 @@ export class ClientsService { c.email, c.telefone, c.cod_vendedor, + ${NOME_VENDEDOR_SUBQ}, c.limite_credito::text, c.ativo, c.pessoa, @@ -138,7 +154,7 @@ export class ClientsService { c.dt_atual::text, GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra FROM vw_clientes c - ${PEDIDOS_JOINS} + ${joins} ${baseWhere} ORDER BY c.nome LIMIT ${limit} OFFSET ${offset} @@ -146,7 +162,7 @@ export class ClientsService { prisma.$queryRawUnsafe<[{ count: string }]>(` SELECT COUNT(*)::text AS count FROM vw_clientes c - ${PEDIDOS_JOINS} + ${joins} ${baseWhere} `), ]); @@ -162,6 +178,7 @@ export class ClientsService { 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, @@ -178,14 +195,14 @@ export class ClientsService { 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.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 - ${PEDIDOS_JOINS} - WHERE c.id_empresa = ${idEmpresa} AND c.id_cliente = ${idCliente} + ${pedidosJoins(idEmpresa)} + WHERE c.id_cliente = ${idCliente} LIMIT 1 `); @@ -201,6 +218,7 @@ export class ClientsService { 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, diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index 21b5de5..55546d1 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -7,11 +7,32 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types'; // Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado const SITUA_PENDENTE = 1; -// tipo='G' em gestao.metavenda = meta geral de valor do mês -const TIPO_META_GERAL = 'G'; +// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo. +const TIPO_META_GLOBAL = 'GL'; +const TIPO_META_GRUPO = 'GR'; -interface MetaRow { +// Metas/produtos vivem na MATRIZ; pedidos usam idEmpresa cru (ex.: 9001 → matriz 1). +function matrizEmpresa(idEmpresa: number): number { + return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; +} + +interface MetaErpRow { + tipo: string; + cod_grupo: number | null; + desc_grupo: string | null; valor: string; + qtdade: string; + peso: string; + vl_fator: string; +} + +interface RealizadoGrupoRow { + cod_grupo: number | null; + grupo: string | null; + pedidos: string; + valor: string; + qtd: string; + peso: string; } interface RepRow { @@ -28,6 +49,7 @@ interface InativoRow { interface InativosPorRepRow { cod_vendedor: number; + nome_vendedor: string | null; inativos_count: string; } @@ -48,25 +70,33 @@ export class DashboardService { const monthStart = new Date(year, month - 1, 1); const monthEnd = new Date(year, month, 0, 23, 59, 59, 999); - // 1. Meta geral do mês — fonte: gestao.metavenda (via vw_metas), tipo='G' - const metaRows = await prisma.$queryRawUnsafe(` - SELECT valor::text + // 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(` + 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 = ${idEmpresa} + WHERE id_empresa = ${idEmpresaMatriz} AND cod_vendedor = ${codVendedor} - AND TRIM(tipo) = '${TIPO_META_GERAL}' - AND ano = ${year} - AND mes = ${month} - LIMIT 1 + AND ano = ${year} + AND mes = ${month} + AND TRIM(tipo) IN ('${TIPO_META_GLOBAL}', '${TIPO_META_GRUPO}') `); - const targetAmount = metaRows[0] ? Number(metaRows[0].valor) : 0; + const glRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GLOBAL); + const grRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GRUPO); + // Total global: usa GL se houver; senão soma as metas por grupo (GR). + const targetAmount = glRows.length + ? glRows.reduce((a, m) => a + Number(m.valor), 0) + : grRows.reduce((a, m) => a + Number(m.valor), 0); + const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const); // 2. Taxas do representante — fonte: gestao.vendedor (via vw_representantes) const repRows = await prisma.$queryRawUnsafe(` SELECT taxa_com::text, COALESCE(permitir_flex, 0) AS permitir_flex FROM vw_representantes - WHERE id_empresa = ${idEmpresa} - AND codigo = ${codVendedor} + WHERE codigo = ${codVendedor} LIMIT 1 `); const commissionRate = repRows[0] ? Number(repRows[0].taxa_com) : 3; @@ -79,7 +109,8 @@ export class DashboardService { }); const flexRate = flexOverride ? Number(flexOverride.taxaFlex) : 1; - // 4. Atingido do mês — pedidos liberados/faturados no ERP (situa 2=Liberado, 4=Faturado) + // 4. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1). + // Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido. const monthStartStr = monthStart.toISOString().slice(0, 10); const monthEndStr = monthEnd.toISOString().slice(0, 10); @@ -94,7 +125,10 @@ export class DashboardService { 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; @@ -103,13 +137,13 @@ export class DashboardService { obs: string | null; } - const [atingidoRows, pedidosMesRows, recentRows] = await Promise.all([ + const [atingidoRows, pedidosMesRows, recentRows, realizadoGrupoRows] = await Promise.all([ prisma.$queryRawUnsafe(` SELECT COALESCE(SUM(total), 0)::text AS total FROM vw_pedidos_erp WHERE id_empresa = ${idEmpresa} AND cod_vendedor = ${codVendedor} - AND situa IN (2, 4) + AND situa NOT IN (1, 5) AND dt_pedido >= '${monthStartStr}' AND dt_pedido <= '${monthEndStr}' `), @@ -123,16 +157,40 @@ export class DashboardService { AND dt_pedido <= '${monthEndStr}' `), prisma.$queryRawUnsafe(` - SELECT id_pedido, num_ped_sar, numero, id_cliente, cod_vendedor, - situa, status_descr, dt_pedido, total::text, desconto_perc::text, obs - FROM vw_pedidos_erp - WHERE id_empresa = ${idEmpresa} - AND cod_vendedor = ${codVendedor} - AND situa != 5 - AND dt_pedido >= '${new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}' - ORDER BY dt_pedido DESC + 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(` + SELECT p.cod_grupo, + COALESCE(NULLIF(TRIM(p.grupo), ''), p.cod_grupo::text) AS grupo, + COUNT(DISTINCT pi.id_pedido)::text AS pedidos, + COALESCE(SUM(pi.total), 0)::text AS valor, + COALESCE(SUM(pi.qtd), 0)::text AS qtd, + COALESCE(SUM(pi.qtd * COALESCE(p.peso_liquido, 0)), 0)::text AS peso + FROM vw_peditens_erp pi + JOIN vw_pedidos_erp e ON e.id_pedido = pi.id_pedido + JOIN vw_produtos p ON p.id_erp = pi.id_produto AND p.id_empresa = ${idEmpresaMatriz} + WHERE e.id_empresa = ${idEmpresa} + AND e.cod_vendedor = ${codVendedor} + AND e.situa NOT IN (1, 5) + AND e.dt_pedido >= '${monthStartStr}' + AND e.dt_pedido <= '${monthEndStr}' + GROUP BY p.cod_grupo, grupo + `), ]); const atingido = Number(atingidoRows[0]?.total ?? 0); @@ -157,10 +215,9 @@ export class DashboardService { FROM vw_clientes c LEFT JOIN vw_pedidos_erp p ON p.id_cliente = c.id_cliente - AND p.id_empresa = c.id_empresa + AND p.id_empresa = ${idEmpresa} AND p.situa != 5 - WHERE c.id_empresa = ${idEmpresa} - AND c.cod_vendedor = ${codVendedor} + WHERE c.cod_vendedor = ${codVendedor} AND c.ativo = 1 GROUP BY c.id_cliente, c.nome HAVING MAX(p.dt_pedido) IS NULL @@ -169,8 +226,41 @@ export class DashboardService { LIMIT 10 `); + // Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo. + const realPorGrupo = new Map(); + for (const r of realizadoGrupoRows) { + if (r.cod_grupo != null) realPorGrupo.set(Number(r.cod_grupo), r); + } + const metasPorGrupo = grRows + .map((m) => { + const cod = m.cod_grupo != null ? Number(m.cod_grupo) : null; + const real = cod != null ? realPorGrupo.get(cod) : undefined; + const valorMeta = Number(m.valor); + const valorReal = real ? Number(real.valor) : 0; + const pesoMeta = Number(m.peso); + const pesoReal = real ? Number(real.peso) : 0; + return { + codigo: cod, + rotulo: m.desc_grupo || real?.grupo || `Grupo ${cod ?? '?'}`, + pedidos: real ? Number(real.pedidos) : 0, + valorMeta, + valorReal, + qtdMeta: Number(m.qtdade), + qtdReal: real ? Number(real.qtd) : 0, + pesoMeta, + pesoReal, + fatorMeta: Number(m.vl_fator), + fatorReal: pesoReal > 0 ? valorReal / pesoReal : 0, + pct: valorMeta > 0 ? Math.round((valorReal / valorMeta) * 100) : 0, + falta: Math.max(0, valorMeta - valorReal), + }; + }) + .sort((a, b) => b.valorMeta - a.valorMeta); + return { meta: { atingido, total: targetAmount, pct, falta }, + metaDimensao, + metasPorGrupo, comissao: { fixa, flex, total: fixa + flex }, pedidosMes, pedidosRecentes: recentRows.map((o) => ({ @@ -178,7 +268,10 @@ export class DashboardService { 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(), @@ -244,30 +337,58 @@ export class DashboardService { // 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(` - SELECT cod_vendedor, COUNT(*)::text AS inativos_count + 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 = c.id_empresa + AND p.id_empresa = ${idEmpresa} AND p.situa != 5 - WHERE c.id_empresa = ${idEmpresa} - AND c.ativo = 1 + WHERE c.ativo = 1 GROUP BY c.id_cliente, c.cod_vendedor HAVING MAX(p.dt_pedido) IS NULL OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}' ) inativos - GROUP BY cod_vendedor + 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), @@ -287,6 +408,7 @@ export class DashboardService { }, inativosPorRep: inativosPorRep.map((r) => ({ codVendedor: Number(r.cod_vendedor), + nomeVendedor: r.nome_vendedor ?? null, inativosCount: parseInt(r.inativos_count, 10), })), syncedAt: now.toISOString(), diff --git a/apps/api/src/app/orders/orders.controller.ts b/apps/api/src/app/orders/orders.controller.ts index 4ebca3e..d8f9826 100644 --- a/apps/api/src/app/orders/orders.controller.ts +++ b/apps/api/src/app/orders/orders.controller.ts @@ -52,6 +52,11 @@ export class OrdersController { return this.orders.create(parsed); } + @Patch(':id/transmit') + transmit(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.orders.transmit(id); + } + @Patch(':id/approve') approve( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index f3cf5be..b6463a5 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -13,8 +13,9 @@ import type { import type { WorkspaceClsStore } from '../workspace/workspace.types'; import { NotificationsService } from '../notifications/notifications.service'; -// Situa SAR: 1=Ag.Aprovação, 2=Aprovado, 3=Cancelado, 4=Faturado +// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado // Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado +const SITUA_ORCAMENTO = 0; const SITUA_PENDENTE = 1; const SITUA_APROVADO = 2; const SITUA_CANCELADO = 3; @@ -78,6 +79,7 @@ export class OrdersService { nome_cliente: string | null; razao_cliente: string | null; cod_vendedor: number; + nome_vendedor: string | null; situa: number; status_descr: string; dt_pedido: Date; @@ -86,25 +88,94 @@ export class OrdersService { obs: string | null; } - const [rows, countRows] = await Promise.all([ - prisma.$queryRawUnsafe(` - 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 - FROM vw_pedidos_erp e - LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente AND c.id_empresa = e.id_empresa - ${filters} - ORDER BY e.dt_pedido DESC - LIMIT ${limit} OFFSET ${offset} - `), + // Pedidos SAR-nativos (Orçamento/Transmitido) — ainda não estão no ERP. + const sarWhere: Prisma.PedidoWhereInput = { + idEmpresa, + ...(role === 'rep' ? { codVendedor } : {}), + ...(idCliente != null ? { idCliente } : {}), + ...(situa != null ? { situa } : {}), + ...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}), + ...(from || to + ? { + dtPedido: { + ...(from ? { gte: new Date(from) } : {}), + ...(to ? { lte: new Date(to) } : {}), + }, + } + : {}), + }; + + const [sarPedidos, countRows] = await Promise.all([ + prisma.pedido.findMany({ where: sarWhere, orderBy: { dtPedido: 'desc' } }), prisma.$queryRawUnsafe<[{ count: string }]>(` SELECT COUNT(*)::text AS count FROM vw_pedidos_erp e ${filters} `), ]); - const total = Number(countRows[0]?.count ?? 0); + const sarCount = sarPedidos.length; + const erpTotal = Number(countRows[0]?.count ?? 0); + const total = sarCount + erpTotal; - const data: PedidoSummary[] = rows.map((o) => ({ + // Paginação combinada: SAR-nativos primeiro (ativos), depois histórico ERP. + const sarSlice = sarPedidos.slice(offset, offset + limit); + const erpNeeded = limit - sarSlice.length; + const erpOffset = Math.max(0, offset - sarCount); + + const erpRows = + erpNeeded > 0 + ? await prisma.$queryRawUnsafe(` + SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor, + e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs, + c.nome AS nome_cliente, c.razao AS razao_cliente, + (SELECT r.nome FROM vw_representantes r + WHERE r.codigo = e.cod_vendedor + LIMIT 1) AS nome_vendedor + FROM vw_pedidos_erp e + LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente + ${filters} + ORDER BY e.dt_pedido DESC + LIMIT ${erpNeeded} OFFSET ${erpOffset} + `) + : []; + + // Resolve nomes (cliente/rep) dos pedidos SAR em lote — views globais. + const cliIds = [...new Set(sarSlice.map((p) => p.idCliente))]; + const repCods = [...new Set(sarSlice.map((p) => p.codVendedor))]; + const [cliNameRows, repNameRows] = await Promise.all([ + cliIds.length + ? prisma.$queryRawUnsafe< + { id_cliente: number; nome: string | null; razao: string | null }[] + >( + `SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`, + ) + : Promise.resolve([]), + repCods.length + ? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>( + `SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`, + ) + : Promise.resolve([]), + ]); + const cliMap = new Map(cliNameRows.map((c) => [Number(c.id_cliente), c])); + const repMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome])); + + const sarData: PedidoSummary[] = sarSlice.map((p) => ({ + id: p.id, + numPedSar: p.numPedSar, + idCliente: p.idCliente, + nomeCliente: cliMap.get(p.idCliente)?.nome ?? null, + razaoCliente: cliMap.get(p.idCliente)?.razao ?? null, + codVendedor: p.codVendedor, + nomeVendedor: repMap.get(p.codVendedor) ?? null, + situa: p.situa, + dtPedido: p.dtPedido.toISOString(), + total: decimalToString(p.total), + descontoPerc: decimalToString(p.descontoPerc), + obs: p.obs, + createdAt: p.createdAt.toISOString(), + fonte: 'sar' as const, + })); + + const erpData: PedidoSummary[] = erpRows.map((o) => ({ id: `erp-${o.id_pedido}`, numPedSar: (o.num_ped_sar ?? '').trim(), numero: Number(o.numero), @@ -112,6 +183,7 @@ export class OrdersService { nomeCliente: o.nome_cliente ?? null, razaoCliente: o.razao_cliente ?? null, codVendedor: Number(o.cod_vendedor), + nomeVendedor: o.nome_vendedor ?? null, // Normaliza situa SIG → SAR para consistência com pedidos SAR situa: sigToSar(Number(o.situa)), statusDescr: o.status_descr, @@ -120,10 +192,10 @@ export class OrdersService { descontoPerc: o.desconto_perc ?? '0', obs: o.obs ?? null, createdAt: new Date(o.dt_pedido).toISOString(), - fonte: 'erp', + fonte: 'erp' as const, })); - return { data, total, page, limit }; + return { data: [...sarData, ...erpData], total, page, limit }; } async findOne(id: string): Promise { @@ -149,7 +221,8 @@ export class OrdersService { return this.mapDetail(o); } - // Cria novo pedido. Valida alçada por codGrupo (codGrupo=0 = default). + // Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a + // notificação ao supervisor acontecem no transmit(), não aqui. // Idempotency-Key: retorna pedido existente se já processado (FR-4.3). async create(dto: CreatePedido): Promise { const prisma = this.cls.get('prisma'); @@ -170,15 +243,6 @@ export class OrdersService { if (existing) return this.mapDetail(existing); } - // Resolve alçadas: (codVendedor, idEmpresa, codGrupo=0) = default - const limitRows = await prisma.alcadaDesconto.findMany({ - where: { codVendedor, idEmpresa }, - }); - const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)])); - const getLimit = (codGrupo: number) => limitMap.get(codGrupo) ?? limitMap.get(0) ?? 5; - - const needsApproval = dto.descontoPerc > getLimit(0); - const itemsData = dto.itens.map((it) => { const descontoValor = Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100; @@ -200,12 +264,11 @@ export class OrdersService { const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100; const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100; - const situa = needsApproval ? SITUA_PENDENTE : SITUA_APROVADO; + const situa = SITUA_ORCAMENTO; - // Gera número sequencial: SAR-NNNNN + // Gera número sequencial GLOBAL: SAR-NNNNN (numPedSar é unique entre empresas). const lastOrder = await prisma.pedido.findFirst({ - where: { idEmpresa }, - orderBy: { createdAt: 'desc' }, + orderBy: { numPedSar: 'desc' }, select: { numPedSar: true }, }); const seq = lastOrder ? parseInt(lastOrder.numPedSar.replace('SAR-', ''), 10) + 1 : 1; @@ -246,15 +309,61 @@ export class OrdersService { }, }); - if (situa === SITUA_PENDENTE) { - void this.notifications.notifySupervisors({ - title: 'Pedido aguardando aprovação', - body: `Pedido ${pedido.numPedSar} — R$ ${pedido.total.toFixed(2).replace('.', ',')}`, - url: `/pedidos/${pedido.id}`, - }); + return this.mapDetail(pedido); + } + + // Transmite um Orçamento (situa 0) → Transmitido (situa 2). + // Alçada de desconto (codGrupo=0 = default) é BLOQUEIO DURO: desconto acima do + // máximo do rep barra a transmissão com mensagem — não há fila de aprovação. + async transmit(id: string): Promise { + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const role = this.cls.get('role'); + const userId = this.cls.get('userId') ?? '0'; + const codVendedor = parseInt(userId, 10); + + // Rep só transmite o próprio orçamento + const repFilter = role === 'rep' ? { codVendedor } : {}; + const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } }); + if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`); + if (pedido.situa !== SITUA_ORCAMENTO) + throw new BadRequestException( + `Pedido não é um orçamento (situa: ${pedido.situa}) — só orçamentos podem ser transmitidos`, + ); + + // Alçada do rep (codGrupo=0 = default; fallback 5%) — bloqueia se desconto acima. + const limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } }); + const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)])); + const limiteMax = limitMap.get(0) ?? 5; + const desconto = Number(pedido.descontoPerc); + if (desconto > limiteMax) { + throw new BadRequestException( + `Desconto de ${desconto}% acima do máximo permitido para você (${limiteMax}%). Reduza o desconto para transmitir o pedido.`, + ); } - return this.mapDetail(pedido); + const now = new Date(); + await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } }); + await prisma.historicoPedido.create({ + data: { + idPedido: id, + situaAnterior: SITUA_ORCAMENTO, + situaNova: SITUA_APROVADO, + changedBy: codVendedor, + changedAt: now, + nota: 'Transmitido', + }, + }); + + const final = await prisma.pedido.findUniqueOrThrow({ + where: { id }, + include: { + itens: { orderBy: { ordem: 'asc' } }, + historico: { orderBy: { changedAt: 'asc' } }, + }, + }); + return this.mapDetail(final); } async approve(id: string, dto: AprovarPedido): Promise { @@ -364,7 +473,37 @@ export class OrdersService { return this.mapDetail(final); } - private mapDetail(o: { + // Resolve nome do cliente (nome + razão) e nome do representante a partir dos + // códigos, lendo das views do ERP. Usado no detalhe de pedidos SAR-nativos. + private async lookupNames( + idCliente: number, + codVendedor: number, + ): Promise<{ + nomeCliente: string | null; + razaoCliente: string | null; + nomeVendedor: string | null; + }> { + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + + // Cliente e representante são cadastros globais (sem id_empresa). + const [cliRows, repRows] = await Promise.all([ + prisma.$queryRawUnsafe<{ nome: string | null; razao: string | null }[]>( + `SELECT nome, razao FROM vw_clientes WHERE id_cliente = ${idCliente} LIMIT 1`, + ), + prisma.$queryRawUnsafe<{ nome: string | null }[]>( + `SELECT nome FROM vw_representantes WHERE codigo = ${codVendedor} LIMIT 1`, + ), + ]); + + return { + nomeCliente: cliRows[0]?.nome ?? null, + razaoCliente: cliRows[0]?.razao ?? null, + nomeVendedor: repRows[0]?.nome ?? null, + }; + } + + private async mapDetail(o: { id: string; numPedSar: string; idCliente: number; @@ -406,12 +545,16 @@ export class OrdersService { nota: string | null; changedAt: Date; }[]; - }): PedidoDetail { + }): Promise { + const names = await this.lookupNames(o.idCliente, o.codVendedor); return { id: o.id, numPedSar: o.numPedSar, idCliente: o.idCliente, + nomeCliente: names.nomeCliente, + razaoCliente: names.razaoCliente, codVendedor: o.codVendedor, + nomeVendedor: names.nomeVendedor, situa: o.situa, dtPedido: o.dtPedido.toISOString(), total: decimalToString(o.total), diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index d801859..c21e791 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,5 +1,106 @@ -// Service Worker SAR — C6 Web Push -// Recebe push events e exibe notificação nativa. Clique abre a URL do payload. +// Service Worker SAR +// C4/NFR-2: cache de API para uso offline (network-first, fallback to cache) +// C6: Web Push +// App shell: stale-while-revalidate para assets estáticos + +const API_CACHE = 'sar-api-v2'; +const SHELL_CACHE = 'sar-shell-v2'; + +// Paths de API que valem ser cacheados para offline +// Auth e mutations (POST/PATCH) nunca são interceptados +const CACHEABLE_API = [ + '/api/v1/clients', + '/api/v1/catalog', + '/api/v1/orders', + '/api/v1/dashboard', + '/api/v1/auth/me', +]; + +// ── Fetch ────────────────────────────────────────────────────────────────────── + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + + if (url.pathname.startsWith('/api/v1/')) { + const cacheable = CACHEABLE_API.some((p) => url.pathname.startsWith(p)); + if (cacheable) { + event.respondWith(networkFirst(request, API_CACHE)); + } + return; + } + + if (request.mode === 'navigate') { + // App shell HTML — network first, cache fallback + event.respondWith(shellNavigate(request)); + return; + } + + // Assets estáticos (JS/CSS/fontes/imagens) — stale-while-revalidate + if (/\.(js|css|woff2?|png|svg|ico)$/.test(url.pathname)) { + event.respondWith(staleWhileRevalidate(request, SHELL_CACHE)); + } +}); + +// Network-first: tenta rede, cai no cache se offline +async function networkFirst(request, cacheName) { + const cache = await caches.open(cacheName); + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await cache.match(request); + if (cached) return cached; + return offlineResponse(); + } +} + +// Navigate: network first; fallback para a raiz cacheada +async function shellNavigate(request) { + const cache = await caches.open(SHELL_CACHE); + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + // Sempre armazena a raiz como fallback universal + cache.put(new Request('/'), response.clone()); + } + return response; + } catch { + const cached = (await cache.match(request)) ?? (await cache.match('/')); + if (cached) return cached; + return offlineResponse(); + } +} + +// Stale-while-revalidate: responde do cache, atualiza em background +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + const networkFetch = fetch(request).then((response) => { + if (response.ok) cache.put(request, response.clone()); + return response; + }); + return cached ?? networkFetch; +} + +function offlineResponse() { + return new Response( + JSON.stringify({ + type: 'sar:offline', + title: 'Sem conexão', + status: 503, + }), + { status: 503, headers: { 'Content-Type': 'application/json' } }, + ); +} + +// ── Push (C6) ───────────────────────────────────────────────────────────────── self.addEventListener('push', (event) => { const data = event.data?.json() ?? {}; diff --git a/apps/web/src/cockpits/rep/ClientsPage.tsx b/apps/web/src/cockpits/rep/ClientsPage.tsx index 5b764f5..c83c844 100644 --- a/apps/web/src/cockpits/rep/ClientsPage.tsx +++ b/apps/web/src/cockpits/rep/ClientsPage.tsx @@ -576,8 +576,15 @@ function CustomerDetailsDrawer({ )} - Cód. Vendedor - {summary.codVendedor} + Representante + + {summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`} + {summary.nomeVendedor && ( + + (cód. {summary.codVendedor}) + + )} + {summary.dtUltimaCompra && ( @@ -787,8 +794,8 @@ function CustomerAnalysisDrawer({ color: urgencyColor, }, { - label: 'Cód. Vendedor', - value: String(summary.codVendedor), + label: 'Representante', + value: summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`, icon: , color: '#64748B', }, diff --git a/apps/web/src/cockpits/rep/NewOrderPage.tsx b/apps/web/src/cockpits/rep/NewOrderPage.tsx index a59dc8e..d226ac5 100644 --- a/apps/web/src/cockpits/rep/NewOrderPage.tsx +++ b/apps/web/src/cockpits/rep/NewOrderPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Alert, + App, AutoComplete, Button, Card, @@ -27,24 +28,20 @@ import { UserOutlined, } from '@ant-design/icons'; import { useNavigate, useSearch } from '@tanstack/react-router'; -import type { ClientSummary, CreatePedido, Pauta, ProdutoSummary } from '@sar/api-interface'; +import type { + ClientSummary, + CreatePedido, + FormaPagamento, + Pauta, + ProdutoSummary, +} from '@sar/api-interface'; import { useClientList, useClientDetail } from '../../lib/queries/clients'; -import { useCatalog, usePautas } from '../../lib/queries/catalog'; +import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog'; import { apiFetch } from '../../lib/api-client'; +import { enqueueOrder } from '../../lib/offline/order-queue'; const { Title, Text } = Typography; -// ─── Condições de pagamento mockadas — substituir por endpoint quando disponível ── -const COND_PAGAMENTO = [ - { value: 1, label: 'À Vista' }, - { value: 2, label: 'Boleto 30 dias' }, - { value: 3, label: 'Boleto 30/60 dias' }, - { value: 4, label: 'Boleto 30/60/90 dias' }, - { value: 5, label: 'Boleto 28/56/84 dias' }, - { value: 6, label: 'PIX' }, - { value: 7, label: 'Cartão de Crédito' }, -]; - // ─── Tipos internos ──────────────────────────────────────────────────────────── type CartItem = { @@ -424,6 +421,7 @@ export function NewOrderPage() { const { clientId: clientIdParam } = useSearch({ strict: false }) as SearchParams; const navigate = useNavigate(); const qc = useQueryClient(); + const { message } = App.useApp(); // ── Dados do cliente ── const [clientSearch, setClientSearch] = useState(''); @@ -437,6 +435,7 @@ export function NewOrderPage() { // ── Campos comerciais ── const { data: pautas = [] } = usePautas(); + const { data: formasPagamento = [] } = useFormasPagamento(); const [idPauta, setIdPauta] = useState(); const [codFormapag, setCodFormapag] = useState(); const [contato, setContato] = useState(''); @@ -491,7 +490,6 @@ export function NewOrderPage() { if (!effectiveClient) throw new Error('Selecione um cliente para continuar.'); if (cart.length === 0) throw new Error('Adicione ao menos um produto ao pedido.'); - // Concatena campos extras em obs enquanto não há campos dedicados no backend const obsCompleta = [ contato ? `Contato: ${contato}` : null, numOC ? `OC: ${numOC}` : null, @@ -517,12 +515,35 @@ export function NewOrderPage() { descontoPerc: it.descontoPerc, })), }; - return apiFetch('/orders', { method: 'POST', body }); + + // Offline: enfileira localmente e sincroniza ao reconectar (FR-4.2 / NFR-2.2) + if (!navigator.onLine) { + await enqueueOrder(body, effectiveClient.nome); + window.dispatchEvent(new CustomEvent('sar:order-queued')); + return null; // sinaliza fluxo offline para onSuccess + } + + return apiFetch('/orders', { method: 'POST', body }) as Promise<{ id: string }>; }, - onSuccess: () => { + onSuccess: (created) => { void qc.invalidateQueries({ queryKey: ['orders'] }); void qc.invalidateQueries({ queryKey: ['clients'] }); - void navigate({ to: '/pedidos' }); + if (!created) { + // Offline: pedido enfileirado — mostra confirmação e fica na tela + setError(null); + setCart([]); + setSelectedClient(null); + setClientSearch(''); + setCart([]); + setIdPauta(undefined); + setCodFormapag(undefined); + setObs(''); + setContato(''); + setNumOC(''); + message.success('Pedido salvo offline — será transmitido ao reconectar'); + return; + } + void navigate({ to: '/pedidos/$id', params: { id: created.id } }); }, onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'), }); @@ -633,7 +654,10 @@ export function NewOrderPage() { allowClear value={codFormapag} onChange={setCodFormapag} - options={COND_PAGAMENTO} + options={formasPagamento.map((f: FormaPagamento) => ({ + value: f.codigo, + label: f.descricao, + }))} /> diff --git a/apps/web/src/cockpits/rep/OrderDetailPage.tsx b/apps/web/src/cockpits/rep/OrderDetailPage.tsx index f0e2964..30f6b61 100644 --- a/apps/web/src/cockpits/rep/OrderDetailPage.tsx +++ b/apps/web/src/cockpits/rep/OrderDetailPage.tsx @@ -20,8 +20,9 @@ import { } from 'antd'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faShareNodes } from '@fortawesome/free-solid-svg-icons'; +import { FilePdfOutlined } from '@ant-design/icons'; import type { TableColumnsType } from 'antd'; -import { Link, useParams } from '@tanstack/react-router'; +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'; @@ -35,6 +36,7 @@ const { TextArea } = Input; // ─── Helpers ────────────────────────────────────────────────────────────────── const SITUA_COLOR: Record = { + 0: 'default', 1: 'warning', 2: 'processing', 3: 'error', @@ -244,12 +246,14 @@ function RejectModal({ export function OrderDetailPage() { const { id } = useParams({ from: '/pedidos/$id' }); + const navigate = useNavigate(); const qc = useQueryClient(); const { data: order, isLoading, error } = useOrderDetail(id); const { data: clientOrders } = useClientOrders(order?.idCliente); const role = getRoleFromToken(); const canAct = role !== 'rep' && order?.situa === 1; + const canTransmit = role === 'rep' && order?.situa === 0; const canShare = role === 'rep' && (order?.situa === 2 || order?.situa === 4) && @@ -288,6 +292,16 @@ export function OrderDetailPage() { }, }); + 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 ; if (error || !order) return ; @@ -297,8 +311,11 @@ export function OrderDetailPage() { ? 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 ( -
+
← Pedidos @@ -322,6 +339,25 @@ export function OrderDetailPage() { {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)}> @@ -359,13 +395,20 @@ export function OrderDetailPage() { /> )} - <Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}> + <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) }}> - Cód. {order.idCliente} + {order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`} </Link> </Descriptions.Item> - <Descriptions.Item label="Rep (cód)">{order.codVendedor}</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> @@ -399,7 +442,7 @@ export function OrderDetailPage() { columns={itemColumns} dataSource={order.itens} pagination={false} - size="small" + size={isOrcamento ? 'middle' : 'small'} style={{ marginBottom: 24 }} /> diff --git a/apps/web/src/cockpits/rep/OrderPrintPage.tsx b/apps/web/src/cockpits/rep/OrderPrintPage.tsx new file mode 100644 index 0000000..b00f7f9 --- /dev/null +++ b/apps/web/src/cockpits/rep/OrderPrintPage.tsx @@ -0,0 +1,430 @@ +import { useEffect } from 'react'; +import { Button, Spin, Alert } from 'antd'; +import { PrinterOutlined, ArrowLeftOutlined } from '@ant-design/icons'; +import { useParams, useNavigate } from '@tanstack/react-router'; +import { SITUA_LABEL } from '@sar/api-interface'; +import { useOrderDetail } from '../../lib/queries/orders'; +import { useClientDetail } from '../../lib/queries/clients'; +import { useCompany } from '../../lib/queries/company'; + +// ─── Paleta / tokens ──────────────────────────────────────────────────────── +const BLUE = '#003B8E'; +const INK = '#1F2937'; +const MUTED = '#64748B'; +const LINE = '#E5EAF0'; + +// ─── Helpers de formatação ────────────────────────────────────────────────── +function money(v: string | number | null | undefined): string { + const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0); + return (isNaN(n as number) ? 0 : (n as number)).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + }); +} +function qty(v: string | number): string { + return Number(v).toLocaleString('pt-BR', { maximumFractionDigits: 3 }); +} +function dateBR(v: string | null | undefined): string { + return v ? new Date(v).toLocaleDateString('pt-BR') : '—'; +} +function doc(raw: string | null | undefined): string { + if (!raw) return '—'; + const d = raw.replace(/\D/g, ''); + if (d.length === 14) return d.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, '$1.$2.$3/$4-$5'); + if (d.length === 11) return d.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4'); + return raw; +} +function phone(raw: string | null | undefined, ddd?: string | null): string { + const d = `${ddd ?? ''}${raw ?? ''}`.replace(/\D/g, ''); + if (d.length === 11) return d.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3'); + if (d.length === 10) return d.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3'); + return raw ?? '—'; +} +function cep(raw: string | null | undefined): string { + const d = (raw ?? '').replace(/\D/g, ''); + return d.length === 8 ? d.replace(/(\d{5})(\d{3})/, '$1-$2') : (raw ?? ''); +} +// Campos char do ERP vêm com padding — limpa, devolve null se vazio. +function tx(s: string | null | undefined): string | null { + const t = (s ?? '').trim(); + return t === '' ? null : t; +} + +// ─── Blocos visuais ───────────────────────────────────────────────────────── +const label: React.CSSProperties = { + fontSize: 9, + fontWeight: 700, + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: MUTED, + display: 'block', + marginBottom: 2, +}; + +function Field({ k, v }: { k: string; v: React.ReactNode }) { + if (v == null || v === '' || v === '—') return null; + return ( + <div style={{ marginBottom: 5 }}> + <span style={label}>{k}</span> + <span style={{ fontSize: 11.5, color: INK }}>{v}</span> + </div> + ); +} + +export function OrderPrintPage() { + const { id } = useParams({ from: '/pedidos/$id/imprimir' }); + const navigate = useNavigate(); + const { data: order, isLoading, error } = useOrderDetail(id); + const { data: client } = useClientDetail(order?.idCliente); + const { data: empresa } = useCompany(); + + // Auto-abre o diálogo de impressão quando tudo carregou. + useEffect(() => { + if (order && empresa) { + const t = setTimeout(() => window.print(), 600); + return () => clearTimeout(t); + } + }, [order, empresa]); + + if (isLoading) return <Spin style={{ display: 'block', marginTop: 80 }} />; + if (error || !order) + return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />; + + const enderecoCli = client + ? [ + tx(client.endereco), + tx(client.numEndereco), + tx(client.bairro), + client.cep ? `CEP ${cep(client.cep)}` : null, + ] + .filter(Boolean) + .join(', ') + : null; + const cgDigits = (client?.cgcpf ?? '').replace(/\D/g, '').length; + const docLabel = cgDigits === 11 ? 'CPF' : 'CNPJ'; + const enderecoEmp = empresa + ? [empresa.endereco, empresa.numero, empresa.complemento, empresa.bairro] + .filter(Boolean) + .join(', ') + : null; + const cidadeEmp = empresa + ? [empresa.cidade, empresa.uf].filter(Boolean).join(' - ') + + (empresa.cep ? ` · CEP ${cep(empresa.cep)}` : '') + : null; + + const clienteNome = + tx(order.razaoCliente) ?? tx(order.nomeCliente) ?? `Cliente ${order.idCliente}`; + const temDesc = Number(order.descontoValor) > 0 || Number(order.descontoPerc) > 0; + + return ( + <div style={{ background: '#EEF2F7', minHeight: '100vh', padding: '24px 0 60px' }}> + {/* Barra de ações (não imprime) */} + <div + className="no-print" + style={{ + maxWidth: 820, + margin: '0 auto 16px', + display: 'flex', + justifyContent: 'space-between', + padding: '0 8px', + }} + > + <Button + icon={<ArrowLeftOutlined />} + onClick={() => navigate({ to: '/pedidos/$id', params: { id } })} + > + Voltar + </Button> + <Button type="primary" icon={<PrinterOutlined />} onClick={() => window.print()}> + Imprimir / Salvar PDF + </Button> + </div> + + {/* Documento A4 */} + <div + className="sar-print" + style={{ + width: '100%', + maxWidth: 820, + margin: '0 auto', + background: '#fff', + boxShadow: '0 4px 24px rgba(0,0,0,0.10)', + borderRadius: 4, + overflow: 'hidden', + fontFamily: "'Plus Jakarta Sans Variable', system-ui, sans-serif", + color: INK, + }} + > + {/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */} + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + padding: '22px 28px', + borderTop: `5px solid ${BLUE}`, + background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)', + borderBottom: `1px solid ${LINE}`, + }} + > + <div style={{ maxWidth: 460 }}> + <div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}> + {empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'} + </div> + <div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div> + <div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}> + {empresa?.cnpj && <>CNPJ {empresa.cnpj}</>} + {empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>} + {enderecoEmp && <div>{enderecoEmp}</div>} + {cidadeEmp && <div>{cidadeEmp}</div>} + {(empresa?.telefone || empresa?.email) && ( + <div> + {empresa?.telefone && <>Tel {phone(empresa.telefone)}</>} + {empresa?.telefone && empresa?.email && <> · </>} + {empresa?.email} + </div> + )} + </div> + </div> + <div style={{ textAlign: 'right' }}> + <div style={{ fontSize: 10, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}> + PEDIDO + </div> + <div style={{ fontSize: 22, fontWeight: 800, color: INK, lineHeight: 1.1 }}> + {order.numPedSar} + </div> + <div + style={{ + display: 'inline-block', + marginTop: 6, + padding: '2px 10px', + borderRadius: 20, + background: `${BLUE}12`, + color: BLUE, + fontSize: 10.5, + fontWeight: 700, + }} + > + {SITUA_LABEL[order.situa] ?? String(order.situa)} + </div> + <div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}> + Emissão: {dateBR(order.dtPedido)} + </div> + </div> + </div> + + {/* ── Cliente + Representante ─────────────────────────────────────── */} + <div style={{ display: 'flex', gap: 0 }}> + <div style={{ flex: 1.4, padding: '16px 28px', borderRight: `1px solid ${LINE}` }}> + <div + style={{ + fontSize: 10, + fontWeight: 800, + color: BLUE, + letterSpacing: '0.1em', + marginBottom: 10, + }} + > + CLIENTE + </div> + <div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 2 }}> + {clienteNome} + </div> + {tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && ( + <div style={{ fontSize: 11, color: MUTED, marginBottom: 8 }}>{tx(client?.nome)}</div> + )} + <div style={{ marginTop: 8 }}> + <Field k={docLabel} v={doc(client?.cgcpf)} /> + <Field k="Inscr. Estadual" v={tx(client?.inscricaoEstadual)} /> + <Field k="Endereço" v={enderecoCli} /> + <Field k="Telefone" v={phone(client?.telefone, client?.ddd)} /> + <Field k="E-mail" v={tx(client?.email)} /> + </div> + </div> + <div style={{ flex: 1, padding: '16px 28px' }}> + <div + style={{ + fontSize: 10, + fontWeight: 800, + color: BLUE, + letterSpacing: '0.1em', + marginBottom: 10, + }} + > + REPRESENTANTE + </div> + <div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 8 }}> + {tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`} + </div> + <Field k="Código" v={String(order.codVendedor)} /> + <Field k="Data do pedido" v={dateBR(order.dtPedido)} /> + <Field k="Nº do pedido" v={order.numPedSar} /> + </div> + </div> + + {/* ── Itens ───────────────────────────────────────────────────────── */} + <div style={{ padding: '8px 28px 0' }}> + <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}> + <thead> + <tr style={{ background: '#F4F7FB' }}> + {['Cód.', 'Produto', 'Qtd', 'Preço un.', 'Desc.', 'Total'].map((h, i) => ( + <th + key={h} + style={{ + textAlign: i >= 2 ? 'right' : 'left', + padding: '8px 8px', + color: MUTED, + fontWeight: 700, + fontSize: 9.5, + letterSpacing: '0.05em', + textTransform: 'uppercase', + borderBottom: `2px solid ${LINE}`, + }} + > + {h} + </th> + ))} + </tr> + </thead> + <tbody> + {order.itens.map((it, idx) => ( + <tr key={it.id} style={{ background: idx % 2 ? '#FBFCFE' : '#fff' }}> + <td + style={{ padding: '7px 8px', color: MUTED, borderBottom: `1px solid ${LINE}` }} + > + {it.codProduto ?? '—'} + </td> + <td style={{ padding: '7px 8px', color: INK, borderBottom: `1px solid ${LINE}` }}> + {it.descProduto ?? '—'} + </td> + <td + style={{ + padding: '7px 8px', + textAlign: 'right', + borderBottom: `1px solid ${LINE}`, + }} + > + {qty(it.qtd)} + </td> + <td + style={{ + padding: '7px 8px', + textAlign: 'right', + borderBottom: `1px solid ${LINE}`, + }} + > + {money(it.precoUnitario)} + </td> + <td + style={{ + padding: '7px 8px', + textAlign: 'right', + color: MUTED, + borderBottom: `1px solid ${LINE}`, + }} + > + {Number(it.descontoPerc) > 0 ? `${Number(it.descontoPerc)}%` : '—'} + </td> + <td + style={{ + padding: '7px 8px', + textAlign: 'right', + fontWeight: 600, + color: INK, + borderBottom: `1px solid ${LINE}`, + }} + > + {money(it.total)} + </td> + </tr> + ))} + </tbody> + </table> + </div> + + {/* ── Totais ──────────────────────────────────────────────────────── */} + <div style={{ display: 'flex', justifyContent: 'flex-end', padding: '14px 28px 4px' }}> + <div style={{ width: 300 }}> + <TotRow k="Total dos produtos" v={money(order.totalProdutos)} /> + {Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />} + {Number(order.totalIcmsst) > 0 && <TotRow k="ICMS-ST" v={money(order.totalIcmsst)} />} + {temDesc && ( + <TotRow + k={`Desconto${Number(order.descontoPerc) > 0 ? ` (${Number(order.descontoPerc)}%)` : ''}`} + v={`- ${money(order.descontoValor)}`} + /> + )} + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 8, + padding: '10px 14px', + background: BLUE, + borderRadius: 6, + color: '#fff', + }} + > + <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.04em' }}>TOTAL</span> + <span style={{ fontSize: 17, fontWeight: 800 }}>{money(order.total)}</span> + </div> + </div> + </div> + + {/* ── Observações + rodapé ────────────────────────────────────────── */} + {order.obs && ( + <div style={{ padding: '10px 28px 0' }}> + <span style={label}>Observações</span> + <div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div> + </div> + )} + <div + style={{ + margin: '18px 28px 0', + padding: '12px 0 18px', + borderTop: `1px solid ${LINE}`, + display: 'flex', + justifyContent: 'space-between', + fontSize: 9.5, + color: '#94A3B8', + }} + > + <span> + Documento sem valor fiscal · Pedido de venda emitido pelo representante via SAR. + </span> + <span>SAR · Powered by JCS Sistemas</span> + </div> + </div> + + {/* CSS de impressão: esconde tudo menos o documento */} + <style>{` + @media print { + @page { size: A4; margin: 10mm; } + body * { visibility: hidden !important; } + .sar-print, .sar-print * { visibility: visible !important; } + .sar-print { position: absolute; left: 0; top: 0; width: 100% !important; + max-width: none !important; box-shadow: none !important; border-radius: 0 !important; } + .no-print { display: none !important; } + } + `}</style> + </div> + ); +} + +function TotRow({ k, v }: { k: string; v: string }) { + return ( + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + padding: '4px 14px', + fontSize: 11.5, + color: MUTED, + }} + > + <span>{k}</span> + <span style={{ color: INK, fontWeight: 600 }}>{v}</span> + </div> + ); +} diff --git a/apps/web/src/cockpits/rep/OrdersPage.tsx b/apps/web/src/cockpits/rep/OrdersPage.tsx index 21a89c9..5506c98 100644 --- a/apps/web/src/cockpits/rep/OrdersPage.tsx +++ b/apps/web/src/cockpits/rep/OrdersPage.tsx @@ -4,6 +4,7 @@ import { Button, Card, Col, + DatePicker, Drawer, Dropdown, Grid, @@ -19,6 +20,9 @@ import { } from 'antd'; import type { TableColumnsType } from 'antd'; import type { MenuProps } from 'antd'; +import type { Dayjs } from 'dayjs'; + +const { RangePicker } = DatePicker; import { CheckCircleOutlined, ClockCircleOutlined, @@ -37,6 +41,8 @@ import { Link, useNavigate } from '@tanstack/react-router'; import type { PedidoSummary } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface'; import { useOrderList, useOrderDetail } from '../../lib/queries/orders'; +import { usePendingOrders } from '../../lib/hooks/usePendingOrders'; +import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue'; const { Title, Text } = Typography; const { useBreakpoint } = Grid; @@ -74,8 +80,9 @@ function periodRange(p: string): { from?: string; to?: string } { // ─── Status Config ──────────────────────────────────────────────────────────── const STATUS: Record<number, { label: string; color: string; rowBg: string; tagColor: string }> = { + 0: { label: 'Orçamento', color: '#475569', rowBg: '#f8fafc', tagColor: 'default' }, 1: { label: 'Ag. Aprovação', color: '#d46b08', rowBg: '#fffbe6', tagColor: 'orange' }, - 2: { label: 'Aprovado', color: '#389e0d', rowBg: '#f6ffed', tagColor: 'green' }, + 2: { label: 'Transmitido', color: '#389e0d', rowBg: '#f6ffed', tagColor: 'green' }, 3: { label: 'Cancelado', color: '#cf1322', rowBg: '#fff1f0', tagColor: 'red' }, 4: { label: 'Faturado', color: '#1d39c4', rowBg: '#f0f5ff', tagColor: 'geekblue' }, }; @@ -220,7 +227,8 @@ function OrderActionsMenu({ key: 'pdf', icon: <FilePdfOutlined />, label: 'Gerar PDF', - onClick: () => alert('PDF em breve'), + disabled: order.fonte === 'erp', + onClick: () => void navigate({ to: '/pedidos/$id/imprimir', params: { id: order.id } }), }, { key: 'cancel', @@ -242,6 +250,7 @@ function OrderActionsMenu({ // ─── OrderDetailDrawer ──────────────────────────────────────────────────────── function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => void }) { + const navigate = useNavigate(); const { data, isLoading } = useOrderDetail(id ?? undefined); const timelineItems = (data?.historico ?? []).map((h) => ({ @@ -292,7 +301,13 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => <Space> <Button onClick={onClose}>Fechar</Button> {data && ( - <Button type="primary" onClick={() => alert('PDF em breve')}> + <Button + type="primary" + icon={<FilePdfOutlined />} + onClick={() => + void navigate({ to: '/pedidos/$id/imprimir', params: { id: data.id } }) + } + > Gerar PDF </Button> )} @@ -333,6 +348,10 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => </Text> )} </Col> + <Col span={24}> + <span style={label}>Representante</span> + <Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text> + </Col> <Col span={12}> <span style={label}>Total</span> <Text strong style={{ color: '#003B8E', fontSize: 16 }}> @@ -461,7 +480,11 @@ function MobileOrderCard({ size="small" icon={<EyeOutlined />} disabled={order.fonte === 'erp'} - onClick={() => onView(order.id)} + onClick={() => + order.situa === 0 + ? void navigate({ to: '/pedidos/$id', params: { id: order.id } }) + : onView(order.id) + } style={{ borderRadius: 6 }} > Ver @@ -488,6 +511,7 @@ export function OrdersPage() { const screens = useBreakpoint(); const isMobile = !screens.md; const { message: msg } = App.useApp(); + const { orders: pendingOrders, refresh: refreshPending } = usePendingOrders(); const stats = useOrderStats(); @@ -495,11 +519,15 @@ export function OrdersPage() { const [query, setQuery] = useState(''); const [situaFilter, setSituaFilter] = useState<number | undefined>(); const [period, setPeriod] = useState(''); + const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null); const [page, setPage] = useState(1); const [drawerOrderId, setDrawerOrderId] = useState<string | null>(null); const limit = 20; - const { from, to } = period ? periodRange(period) : {}; + // Intervalo customizado (datas antigas) tem prioridade sobre o atalho de período. + const periodR = period ? periodRange(period) : {}; + const from = range ? range[0].format('YYYY-MM-DD') : periodR.from; + const to = range ? range[1].format('YYYY-MM-DD') : periodR.to; const { data, isLoading, isFetching } = useOrderList({ numPedSar: query || undefined, @@ -513,7 +541,7 @@ export function OrdersPage() { const rows = data?.data ?? []; const total = data?.total ?? 0; - const hasFilters = !!query || !!situaFilter || !!period; + const hasFilters = !!query || !!situaFilter || !!period || !!range; function commitSearch() { setQuery(search.trim()); @@ -525,6 +553,7 @@ export function OrdersPage() { setQuery(''); setSituaFilter(undefined); setPeriod(''); + setRange(null); setPage(1); } @@ -623,7 +652,11 @@ export function OrdersPage() { style={{ borderRadius: 6 }} title="Ver detalhes" disabled={row.fonte === 'erp'} - onClick={() => setDrawerOrderId(row.id)} + onClick={() => + row.situa === 0 + ? void navigate({ to: '/pedidos/$id', params: { id: row.id } }) + : setDrawerOrderId(row.id) + } /> <OrderActionsMenu order={row} onView={(id) => setDrawerOrderId(id)} /> </Space> @@ -683,7 +716,7 @@ export function OrdersPage() { > <Row gutter={[12, 12]} align="middle"> {/* Busca */} - <Col xs={24} md={8}> + <Col xs={24} md={6}> <div style={{ position: 'relative' }}> <SearchOutlined style={{ @@ -719,7 +752,7 @@ export function OrdersPage() { </Col> {/* Status */} - <Col xs={12} sm={8} md={5}> + <Col xs={12} sm={8} md={4}> <Select style={{ width: '100%' }} placeholder="Status" @@ -730,16 +763,17 @@ export function OrdersPage() { setPage(1); }} options={[ + { value: 0, label: 'Orçamento' }, { value: 1, label: 'Ag. Aprovação' }, - { value: 2, label: 'Aprovado' }, + { value: 2, label: 'Transmitido' }, { value: 3, label: 'Cancelado' }, { value: 4, label: 'Faturado' }, ]} /> </Col> - {/* Período */} - <Col xs={12} sm={8} md={5}> + {/* Período (atalho) */} + <Col xs={12} sm={8} md={4}> <Select style={{ width: '100%' }} placeholder="Período" @@ -747,6 +781,7 @@ export function OrdersPage() { value={period || undefined} onChange={(v) => { setPeriod(v ?? ''); + if (v) setRange(null); // atalho limpa o intervalo customizado setPage(1); }} options={[ @@ -757,8 +792,28 @@ export function OrdersPage() { /> </Col> + {/* Intervalo de datas (para pedidos antigos) */} + <Col xs={24} sm={16} md={6}> + <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]]); + setPeriod(''); // intervalo customizado tem prioridade + } else { + setRange(null); + } + setPage(1); + }} + /> + </Col> + {/* Limpar */} - <Col xs={12} sm={8} md={3}> + <Col xs={12} sm={8} md={4}> <Button style={{ width: '100%', borderRadius: 6 }} icon={<ClearOutlined />} @@ -778,6 +833,68 @@ export function OrdersPage() { </Row> </Card> + {/* ── Pedidos offline pendentes ─────────────────────────────────── */} + {pendingOrders.length > 0 && ( + <Card + style={{ borderRadius: 10, border: '1px solid #faad14', marginBottom: 16 }} + styles={{ body: { padding: '12px 16px' } }} + > + <div style={{ fontWeight: 600, marginBottom: 8, color: '#d48806' }}> + {pendingOrders.length === 1 + ? '1 pedido aguardando sincronização' + : `${pendingOrders.length} pedidos aguardando sincronização`} + </div> + {pendingOrders.map((o) => ( + <div + key={o.idempotencyKey} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '6px 0', + borderTop: '1px solid #fde8a4', + fontSize: 13, + }} + > + <span> + <Tag color={o.status === 'failed' ? 'error' : 'warning'}> + {o.status === 'failed' ? 'Falha' : 'Pendente'} + </Tag> + <span style={{ fontWeight: 500 }}>{o.clienteNome}</span> + {o.failReason && ( + <span style={{ color: '#cf1322', marginLeft: 8 }}>— {o.failReason}</span> + )} + <span style={{ color: '#94a3b8', marginLeft: 8 }}> + {new Date(o.createdAt).toLocaleString('pt-BR')} + </span> + </span> + <Space size={4}> + {o.status === 'failed' && ( + <Button + size="small" + onClick={() => { + void retryPendingOrder(o.idempotencyKey).then(refreshPending); + }} + > + Tentar novamente + </Button> + )} + <Button + size="small" + danger + onClick={() => { + void removePendingOrder(o.idempotencyKey).then(refreshPending); + void msg.success('Pedido removido da fila'); + }} + > + Descartar + </Button> + </Space> + </div> + ))} + </Card> + )} + {/* ── Lista / tabela ────────────────────────────────────────────── */} {isLoading ? ( <div style={{ textAlign: 'center', padding: 64 }}> @@ -845,7 +962,9 @@ export function OrdersPage() { scroll={{ x: 900 }} onRow={(row) => ({ onClick: () => { - if (row.fonte !== 'erp') setDrawerOrderId(row.id); + // Orçamento abre a tela grande de detalhe (com Transmitir); demais, o drawer. + if (row.situa === 0) void navigate({ to: '/pedidos/$id', params: { id: row.id } }); + else if (row.fonte !== 'erp') setDrawerOrderId(row.id); }, style: { background: STATUS[row.situa]?.rowBg ?? '#fff', diff --git a/apps/web/src/cockpits/rep/RepPainel.tsx b/apps/web/src/cockpits/rep/RepPainel.tsx index 35810b5..ae84889 100644 --- a/apps/web/src/cockpits/rep/RepPainel.tsx +++ b/apps/web/src/cockpits/rep/RepPainel.tsx @@ -1,12 +1,14 @@ -import { Card, Col, Flex, Progress, Row, Skeleton, Space, Tag, Typography } from 'antd'; +import { Card, Col, Flex, Progress, Row, Skeleton, Space, Table, Tag, Typography } from 'antd'; +import type { TableColumnsType } from 'antd'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowTrendUp, + faBullseye, faCircleExclamation, faClipboardList, } from '@fortawesome/free-solid-svg-icons'; import { Link } from '@tanstack/react-router'; -import type { PedidoSummary } from '@sar/api-interface'; +import type { MetaItem, PedidoSummary } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface'; import { useRepDashboard } from '../../lib/queries/dashboard'; import { useCurrentUser } from '../../lib/queries/auth'; @@ -38,6 +40,97 @@ function today(): string { }); } +function num(v: number, dec = 0): string { + return v.toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec }); +} + +// Célula "realizado / meta" — realizado em destaque (verde se bateu), meta abaixo. +function MetaCell({ + real, + meta, + money, + dec = 0, +}: { + real: number; + meta: number; + money?: boolean; + dec?: number; +}) { + const f = (v: number) => (money ? fmt(v) : num(v, dec)); + const ok = meta > 0 && real >= meta; + return ( + <Space orientation="vertical" size={0} style={{ lineHeight: 1.15 }}> + <Text strong className="tabular-nums" style={{ color: ok ? 'var(--green)' : undefined }}> + {f(real)} + </Text> + <Text type="secondary" className="tabular-nums" style={{ fontSize: 'var(--text-xs)' }}> + / {f(meta)} + </Text> + </Space> + ); +} + +const metaColumns: TableColumnsType<MetaItem> = [ + { + title: 'Grupo', + dataIndex: 'rotulo', + key: 'rotulo', + fixed: 'left', + width: 180, + render: (v: string) => <Text strong>{v}</Text>, + }, + { + title: 'Pedidos', + dataIndex: 'pedidos', + key: 'pedidos', + align: 'right', + width: 80, + render: (v: number) => <span className="tabular-nums">{num(v)}</span>, + }, + { + title: 'Qtde', + key: 'qtd', + align: 'right', + width: 110, + render: (_: unknown, r: MetaItem) => <MetaCell real={r.qtdReal} meta={r.qtdMeta} />, + }, + { + title: 'Peso (kg)', + key: 'peso', + align: 'right', + width: 120, + render: (_: unknown, r: MetaItem) => <MetaCell real={r.pesoReal} meta={r.pesoMeta} />, + }, + { + title: 'Valor', + key: 'valor', + align: 'right', + width: 160, + render: (_: unknown, r: MetaItem) => <MetaCell real={r.valorReal} meta={r.valorMeta} money />, + }, + { + title: 'Fator (R$/kg)', + key: 'fator', + align: 'right', + width: 110, + render: (_: unknown, r: MetaItem) => <MetaCell real={r.fatorReal} meta={r.fatorMeta} dec={2} />, + }, + { + title: '% da meta (valor)', + key: 'pct', + align: 'center', + width: 160, + render: (_: unknown, r: MetaItem) => ( + <Progress + percent={Math.min(r.pct, 100)} + size="small" + format={() => `${r.pct}%`} + strokeColor={r.pct >= 100 ? 'var(--green)' : 'var(--jcs-blue)'} + /> + ), + }, +]; + export function RepPainel() { const { data, isLoading } = useRepDashboard(); const { data: user } = useCurrentUser(); @@ -61,7 +154,8 @@ export function RepPainel() { ); } - const { meta, comissao, pedidosMes, pedidosRecentes, clientesInativos, syncedAt } = data; + const { meta, metasPorGrupo, comissao, pedidosMes, pedidosRecentes, clientesInativos, syncedAt } = + data; return ( <Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}> @@ -162,6 +256,82 @@ export function RepPainel() { </Col> </Row> + {/* Metas por Grupo — acompanhamento multi-medida do mês */} + {metasPorGrupo.length > 0 && ( + <Card + title={ + <Space> + <FontAwesomeIcon icon={faBullseye} style={{ color: 'var(--jcs-blue)' }} /> + Metas por Grupo + </Space> + } + extra={ + <Tag color={meta.pct >= 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'}> + {meta.pct}% no valor total + </Tag> + } + > + <Table<MetaItem> + rowKey={(r) => String(r.codigo)} + columns={metaColumns} + dataSource={metasPorGrupo} + size="small" + pagination={false} + scroll={{ x: 820 }} + summary={(rows) => { + const t = rows.reduce( + (a, r) => ({ + pedidos: a.pedidos + r.pedidos, + qtdReal: a.qtdReal + r.qtdReal, + qtdMeta: a.qtdMeta + r.qtdMeta, + pesoReal: a.pesoReal + r.pesoReal, + pesoMeta: a.pesoMeta + r.pesoMeta, + valorReal: a.valorReal + r.valorReal, + valorMeta: a.valorMeta + r.valorMeta, + }), + { + pedidos: 0, + qtdReal: 0, + qtdMeta: 0, + pesoReal: 0, + pesoMeta: 0, + valorReal: 0, + valorMeta: 0, + }, + ); + const pctTotal = t.valorMeta > 0 ? Math.round((t.valorReal / t.valorMeta) * 100) : 0; + const fatorReal = t.pesoReal > 0 ? t.valorReal / t.pesoReal : 0; + const fatorMeta = t.pesoMeta > 0 ? t.valorMeta / t.pesoMeta : 0; + return ( + <Table.Summary.Row style={{ background: 'var(--bg-surface-alt)' }}> + <Table.Summary.Cell index={0}> + <Text strong>Total</Text> + </Table.Summary.Cell> + <Table.Summary.Cell index={1} align="right"> + <span className="tabular-nums">{num(t.pedidos)}</span> + </Table.Summary.Cell> + <Table.Summary.Cell index={2} align="right"> + <MetaCell real={t.qtdReal} meta={t.qtdMeta} /> + </Table.Summary.Cell> + <Table.Summary.Cell index={3} align="right"> + <MetaCell real={t.pesoReal} meta={t.pesoMeta} /> + </Table.Summary.Cell> + <Table.Summary.Cell index={4} align="right"> + <MetaCell real={t.valorReal} meta={t.valorMeta} money /> + </Table.Summary.Cell> + <Table.Summary.Cell index={5} align="right"> + <MetaCell real={fatorReal} meta={fatorMeta} dec={2} /> + </Table.Summary.Cell> + <Table.Summary.Cell index={6} align="center"> + <Text strong>{pctTotal}%</Text> + </Table.Summary.Cell> + </Table.Summary.Row> + ); + }} + /> + </Card> + )} + {/* Linha 2 — Clientes inativos + Pedidos recentes */} <Row gutter={[24, 24]}> <Col xs={24} lg={12}> @@ -245,7 +415,7 @@ export function RepPainel() { </Text> </Link> <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> - Cód. cliente {o.idCliente} + {o.razaoCliente ?? o.nomeCliente ?? `Cód. cliente ${o.idCliente}`} </Text> </Space> <Flex gap={8} align="center"> diff --git a/apps/web/src/cockpits/supervisor/ApprovalQueuePage.tsx b/apps/web/src/cockpits/supervisor/ApprovalQueuePage.tsx index bd8f378..ec91d4a 100644 --- a/apps/web/src/cockpits/supervisor/ApprovalQueuePage.tsx +++ b/apps/web/src/cockpits/supervisor/ApprovalQueuePage.tsx @@ -21,8 +21,19 @@ const columns: TableColumnsType<PedidoSummary> = [ </Link> ), }, - { title: 'Rep (cód)', dataIndex: 'codVendedor', width: 100 }, - { title: 'Cliente (cód)', dataIndex: 'idCliente', width: 110 }, + { + 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', diff --git a/apps/web/src/cockpits/supervisor/SupervisorPainel.tsx b/apps/web/src/cockpits/supervisor/SupervisorPainel.tsx index e3f62af..47f5b55 100644 --- a/apps/web/src/cockpits/supervisor/SupervisorPainel.tsx +++ b/apps/web/src/cockpits/supervisor/SupervisorPainel.tsx @@ -49,8 +49,19 @@ const queueColumns: TableColumnsType<PedidoSummary> = [ </Link> ), }, - { title: 'Rep (cód)', dataIndex: 'codVendedor', width: 100 }, - { title: 'Cliente (cód)', dataIndex: 'idCliente', width: 110 }, + { + 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', @@ -263,7 +274,12 @@ export function SupervisorPainel() { }} > <Space orientation="vertical" size={0}> - <Text strong>Rep cód. {r.codVendedor}</Text> + <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'} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 43c15b0..18e0fea 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -1,9 +1,11 @@ import { useState, type ReactNode } from 'react'; -import { Button, Flex, Tooltip } from 'antd'; -import { PlusOutlined } from '@ant-design/icons'; +import { Alert, Button, Flex, Tooltip } from 'antd'; +import { PlusOutlined, WifiOutlined } from '@ant-design/icons'; import { useNavigate } from '@tanstack/react-router'; import { Topbar } from './Topbar'; import { Sidebar } from './Sidebar'; +import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus'; +import { useOfflineSync } from '../../lib/hooks/useOfflineSync'; interface AppShellProps { children: ReactNode; @@ -15,12 +17,23 @@ interface AppShellProps { * Variante mobile (Rafael) com bottom nav virá em ShellMobile separado. */ export function AppShell({ children }: AppShellProps) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_sidebarOpen, setSidebarOpen] = useState(true); + const [, setSidebarOpen] = useState(true); const navigate = useNavigate(); + const isOnline = useNetworkStatus(); + useOfflineSync(); return ( <Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}> + {!isOnline && ( + <Alert + type="warning" + icon={<WifiOutlined />} + showIcon + banner + message="Sem conexão — pedidos lançados ficam salvos e serão enviados ao reconectar" + style={{ padding: '6px 16px', fontSize: 13 }} + /> + )} <Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} /> <Flex flex={1}> <Sidebar /> diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index d6bc599..c770873 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { Badge, Menu } from 'antd'; +import { Menu } from 'antd'; import { useLocation, useNavigate } from '@tanstack/react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { @@ -11,10 +11,8 @@ import { faGear, faPercent, faFileInvoiceDollar, - faCheckCircle, } from '@fortawesome/free-solid-svg-icons'; import type { ItemType } from 'antd/es/menu/interface'; -import { useOrderList } from '../../lib/queries/orders'; /** * Sidebar canônica do SAR (260px fixa — brand.md). @@ -23,9 +21,6 @@ import { useOrderList } from '../../lib/queries/orders'; export function Sidebar() { const navigate = useNavigate(); const location = useLocation(); - const { data: pendingOrders } = useOrderList({ status: 'pending_approval', limit: 1 }); - const pendingCount = pendingOrders?.total ?? 0; - const items: ItemType[] = [ { key: '/', @@ -57,18 +52,6 @@ export function Sidebar() { icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />, label: 'Pedidos', }, - { - key: '/aprovacoes', - icon: <FontAwesomeIcon icon={faCheckCircle} fixedWidth />, - label: ( - <span> - Aprovações{' '} - {pendingCount > 0 && ( - <Badge count={pendingCount} size="small" style={{ marginLeft: 4 }} /> - )} - </span> - ), - }, { key: '/comissao', icon: <FontAwesomeIcon icon={faPercent} fixedWidth />, diff --git a/apps/web/src/lib/hooks/useNetworkStatus.ts b/apps/web/src/lib/hooks/useNetworkStatus.ts new file mode 100644 index 0000000..822a77b --- /dev/null +++ b/apps/web/src/lib/hooks/useNetworkStatus.ts @@ -0,0 +1,18 @@ +import { useState, useEffect } from 'react'; + +export function useNetworkStatus(): boolean { + const [isOnline, setIsOnline] = useState(() => navigator.onLine); + + useEffect(() => { + const up = () => setIsOnline(true); + const down = () => setIsOnline(false); + window.addEventListener('online', up); + window.addEventListener('offline', down); + return () => { + window.removeEventListener('online', up); + window.removeEventListener('offline', down); + }; + }, []); + + return isOnline; +} diff --git a/apps/web/src/lib/hooks/useOfflineSync.ts b/apps/web/src/lib/hooks/useOfflineSync.ts new file mode 100644 index 0000000..eac8f58 --- /dev/null +++ b/apps/web/src/lib/hooks/useOfflineSync.ts @@ -0,0 +1,63 @@ +// Auto-sync da fila offline ao recuperar conexão. +// NFR-2.3: detecta retorno de conexão e sincroniza sem ação do usuário. +// NFR-2.4: falhas visíveis — nunca descarta pedido silenciosamente. + +import { useEffect, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { + listPendingOrders, + removePendingOrder, + markOrderFailed, + type PendingOrder, +} from '../offline/order-queue'; +import { apiFetch } from '../api-client'; + +export function useOfflineSync() { + const qc = useQueryClient(); + + const sync = useCallback(async () => { + const pending: PendingOrder[] = await listPendingOrders(); + const toSync = pending.filter((o: PendingOrder) => o.status === 'pending'); + if (toSync.length === 0) return; + + for (const order of toSync as PendingOrder[]) { + try { + const created = (await apiFetch('/orders', { + method: 'POST', + body: order.payload, + })) as { id: string }; + + // Tenta transmitir — bloqueio duro se acima da alçada; deixa como Orçamento + try { + await apiFetch(`/orders/${created.id}/transmit`, { method: 'PATCH' }); + } catch { + // Desconto acima da alçada: pedido fica como Orçamento, rep transmite manualmente + } + + await removePendingOrder(order.idempotencyKey); + } catch (e) { + const reason = e instanceof Error ? e.message : 'Erro ao sincronizar pedido'; + await markOrderFailed(order.idempotencyKey, reason); + } + } + + // Notifica UI para re-render das listas + window.dispatchEvent(new CustomEvent('sar:sync-complete')); + void qc.invalidateQueries({ queryKey: ['orders'] }); + void qc.invalidateQueries({ queryKey: ['dashboard'] }); + }, [qc]); + + useEffect(() => { + // Sync imediato no mount se houver fila e conexão + if (navigator.onLine) void sync(); + + const handleOnline = () => void sync(); + const handleRequest = () => void sync(); + window.addEventListener('online', handleOnline); + window.addEventListener('sar:sync-request', handleRequest); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('sar:sync-request', handleRequest); + }; + }, [sync]); +} diff --git a/apps/web/src/lib/hooks/usePendingOrders.ts b/apps/web/src/lib/hooks/usePendingOrders.ts new file mode 100644 index 0000000..552fed1 --- /dev/null +++ b/apps/web/src/lib/hooks/usePendingOrders.ts @@ -0,0 +1,27 @@ +import { useState, useEffect, useCallback } from 'react'; +import { listPendingOrders, type PendingOrder } from '../offline/order-queue'; + +export function usePendingOrders() { + const [orders, setOrders] = useState<PendingOrder[]>([]); + + const refresh = useCallback(async () => { + const pending = await listPendingOrders(); + setOrders(pending); + }, []); + + useEffect(() => { + void refresh(); + + const handle = () => void refresh(); + window.addEventListener('sar:sync-complete', handle); + window.addEventListener('sar:sync-request', handle); + window.addEventListener('sar:order-queued', handle); + return () => { + window.removeEventListener('sar:sync-complete', handle); + window.removeEventListener('sar:sync-request', handle); + window.removeEventListener('sar:order-queued', handle); + }; + }, [refresh]); + + return { orders, refresh }; +} diff --git a/apps/web/src/lib/offline/idb.ts b/apps/web/src/lib/offline/idb.ts new file mode 100644 index 0000000..d8f6c9c --- /dev/null +++ b/apps/web/src/lib/offline/idb.ts @@ -0,0 +1,54 @@ +// Wrappers mínimos sobre IndexedDB nativo — sem dependências externas. +// Todos os stores do SAR offline vivem em um único banco versionado. + +const DB_NAME = 'sar-offline'; +const DB_VERSION = 1; + +export const STORE_PENDING_ORDERS = 'pending-orders'; + +let _db: IDBDatabase | null = null; + +function openDB(): Promise<IDBDatabase> { + if (_db) return Promise.resolve(_db); + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = (e) => { + const db = (e.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_PENDING_ORDERS)) { + db.createObjectStore(STORE_PENDING_ORDERS, { keyPath: 'idempotencyKey' }); + } + }; + req.onsuccess = () => { + _db = req.result; + resolve(_db); + }; + req.onerror = () => reject(req.error); + }); +} + +export async function idbGetAll<T>(store: string): Promise<T[]> { + const db = await openDB(); + return new Promise((resolve, reject) => { + const req = db.transaction(store, 'readonly').objectStore(store).getAll(); + req.onsuccess = () => resolve(req.result as T[]); + req.onerror = () => reject(req.error); + }); +} + +export async function idbPut<T>(store: string, value: T): Promise<void> { + const db = await openDB(); + return new Promise((resolve, reject) => { + const req = db.transaction(store, 'readwrite').objectStore(store).put(value); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +export async function idbDelete(store: string, key: string): Promise<void> { + const db = await openDB(); + return new Promise((resolve, reject) => { + const req = db.transaction(store, 'readwrite').objectStore(store).delete(key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} diff --git a/apps/web/src/lib/offline/order-queue.ts b/apps/web/src/lib/offline/order-queue.ts new file mode 100644 index 0000000..d04531f --- /dev/null +++ b/apps/web/src/lib/offline/order-queue.ts @@ -0,0 +1,63 @@ +// Fila de pedidos pendentes de sync (IndexedDB). +// FR-4.2: lançamento funciona completamente offline. +// FR-4.3: Idempotency-Key gerado localmente antes do envio. +// FR-4.11: falhas de sync nunca descartadas silenciosamente. + +import type { CreatePedido } from '@sar/api-interface'; +import { idbGetAll, idbPut, idbDelete, STORE_PENDING_ORDERS } from './idb'; + +export interface PendingOrder { + idempotencyKey: string; // keyPath do IndexedDB + payload: CreatePedido; + clienteNome: string; + status: 'pending' | 'failed'; + failReason?: string; + createdAt: string; +} + +export function listPendingOrders(): Promise<PendingOrder[]> { + return idbGetAll<PendingOrder>(STORE_PENDING_ORDERS); +} + +export async function enqueueOrder( + payload: CreatePedido, + clienteNome: string, +): Promise<PendingOrder> { + const key = payload.idempotencyKey ?? crypto.randomUUID(); + const order: PendingOrder = { + idempotencyKey: key, + payload: { ...payload, idempotencyKey: key }, + clienteNome, + status: 'pending', + createdAt: new Date().toISOString(), + }; + await idbPut<PendingOrder>(STORE_PENDING_ORDERS, order); + return order; +} + +export async function removePendingOrder(idempotencyKey: string): Promise<void> { + return idbDelete(STORE_PENDING_ORDERS, idempotencyKey); +} + +export async function markOrderFailed(idempotencyKey: string, reason: string): Promise<void> { + const all = await listPendingOrders(); + const order = all.find((o) => o.idempotencyKey === idempotencyKey); + if (!order) return; + await idbPut<PendingOrder>(STORE_PENDING_ORDERS, { + ...order, + status: 'failed', + failReason: reason, + }); +} + +export async function retryPendingOrder(idempotencyKey: string): Promise<void> { + const all = await listPendingOrders(); + const order = all.find((o) => o.idempotencyKey === idempotencyKey); + if (!order) return; + await idbPut<PendingOrder>(STORE_PENDING_ORDERS, { + ...order, + status: 'pending', + failReason: undefined, + }); + window.dispatchEvent(new CustomEvent('sar:sync-request')); +} diff --git a/apps/web/src/lib/queries/catalog.ts b/apps/web/src/lib/queries/catalog.ts index bcab6e0..e7c9954 100644 --- a/apps/web/src/lib/queries/catalog.ts +++ b/apps/web/src/lib/queries/catalog.ts @@ -1,8 +1,10 @@ import { useQuery } from '@tanstack/react-query'; import { + FormaPagamentoSchema, PautaSchema, ProdutoListResponseSchema, ProdutoDetailSchema, + type FormaPagamento, type ProdutoListQuery, type ProdutoListResponse, type ProdutoDetail, @@ -22,6 +24,17 @@ export function usePautas() { }); } +export function useFormasPagamento() { + return useQuery<FormaPagamento[]>({ + queryKey: ['catalog', 'payment-methods'], + queryFn: async () => { + const res = await apiFetch('/catalog/payment-methods'); + return z.array(FormaPagamentoSchema).parse(res); + }, + staleTime: 60 * 60 * 1000, + }); +} + export function useCatalog(params: Partial<ProdutoListQuery> = {}) { const search = new URLSearchParams(); if (params.q) search.set('q', params.q); diff --git a/apps/web/src/lib/queries/company.ts b/apps/web/src/lib/queries/company.ts new file mode 100644 index 0000000..26dde88 --- /dev/null +++ b/apps/web/src/lib/queries/company.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { EmpresaInfoSchema, type EmpresaInfo } from '@sar/api-interface'; +import { apiFetch } from '../api-client'; + +// Dados da empresa matriz (cabeçalho do PDF do pedido). Cache longo — muda raramente. +export function useCompany() { + return useQuery<EmpresaInfo, Error>({ + queryKey: ['company'], + queryFn: async () => EmpresaInfoSchema.parse(await apiFetch('/catalog/company')), + staleTime: 1000 * 60 * 30, + }); +} diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 06faf38..d43526d 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -12,6 +12,7 @@ import { ClientsPage } from '../cockpits/rep/ClientsPage'; import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage'; import { OrdersPage } from '../cockpits/rep/OrdersPage'; import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage'; +import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage'; import { NewOrderPage } from '../cockpits/rep/NewOrderPage'; import { CatalogPage } from '../cockpits/rep/CatalogPage'; import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage'; @@ -98,6 +99,12 @@ const pedidoDetailRoute = createRoute({ component: OrderDetailPage, }); +const pedidoPrintRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/pedidos/$id/imprimir', + component: OrderPrintPage, +}); + const catalogoRoute = createRoute({ getParentRoute: () => rootRoute, path: '/catalogo', @@ -118,6 +125,7 @@ const routeTree = rootRoute.addChildren([ pedidosRoute, novoOrderRoute, pedidoDetailRoute, + pedidoPrintRoute, catalogoRoute, aprovacoes, ]); diff --git a/libs/shared/api-interface/src/index.ts b/libs/shared/api-interface/src/index.ts index a13669c..6cda01b 100644 --- a/libs/shared/api-interface/src/index.ts +++ b/libs/shared/api-interface/src/index.ts @@ -5,3 +5,4 @@ export * from './lib/order.contract'; export * from './lib/product.contract'; export * from './lib/dashboard.contract'; export * from './lib/notifications.contract'; +export * from './lib/company.contract'; diff --git a/libs/shared/api-interface/src/lib/client.contract.ts b/libs/shared/api-interface/src/lib/client.contract.ts index efd9528..545cfe2 100644 --- a/libs/shared/api-interface/src/lib/client.contract.ts +++ b/libs/shared/api-interface/src/lib/client.contract.ts @@ -20,6 +20,7 @@ export const ClientSummarySchema = z.object({ email: z.string().nullable(), telefone: z.string().nullable(), codVendedor: z.number().int(), + nomeVendedor: z.string().nullable().optional(), limiteCreditoStr: z.string().nullable(), activityStatus: ActivityStatusSchema, dtUltimaCompra: z.iso.datetime().nullable(), diff --git a/libs/shared/api-interface/src/lib/company.contract.ts b/libs/shared/api-interface/src/lib/company.contract.ts new file mode 100644 index 0000000..fc5e6fa --- /dev/null +++ b/libs/shared/api-interface/src/lib/company.contract.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +// Dados legais da empresa matriz (a que fatura o pedido) — cabeçalho do PDF. +// Fonte: gestao.empresa (matriz) + vw_municipios. +export const EmpresaInfoSchema = z.object({ + idEmpresa: z.number().int(), + razaoSocial: z.string(), + nomeFantasia: z.string().nullable(), + cnpj: z.string().nullable(), + inscricaoEstadual: z.string().nullable(), + endereco: z.string().nullable(), + numero: z.string().nullable(), + complemento: z.string().nullable(), + bairro: z.string().nullable(), + cidade: z.string().nullable(), + uf: z.string().nullable(), + cep: z.string().nullable(), + telefone: z.string().nullable(), + email: z.string().nullable(), +}); +export type EmpresaInfo = z.infer<typeof EmpresaInfoSchema>; diff --git a/libs/shared/api-interface/src/lib/dashboard.contract.ts b/libs/shared/api-interface/src/lib/dashboard.contract.ts index 612dc8d..d9c951a 100644 --- a/libs/shared/api-interface/src/lib/dashboard.contract.ts +++ b/libs/shared/api-interface/src/lib/dashboard.contract.ts @@ -11,6 +11,31 @@ export const ClienteInativoSchema = z.object({ }); export type ClienteInativo = z.infer<typeof ClienteInativoSchema>; +// Dimensão de meta. O ERP (vw_metas.tipo) define como o cliente acompanha metas: +// GL = global, GR = por grupo. Motor único; outras dimensões (marca/subgrupo/ +// produto) entram aqui depois sem mudar a forma. +export const MetaDimensaoSchema = z.enum(['global', 'grupo']); +export type MetaDimensao = z.infer<typeof MetaDimensaoSchema>; + +// Linha de meta vs realizado por grupo, multi-medida (codigo=null = rollup global). +// fator = R$/kg (valor/peso); o ERP guarda vl_fator na meta. +export const MetaItemSchema = z.object({ + codigo: z.number().int().nullable(), + rotulo: z.string(), + pedidos: z.number().int(), // qtd de pedidos faturados no grupo (realizado) + valorMeta: z.number(), + valorReal: z.number(), + qtdMeta: z.number(), + qtdReal: z.number(), + pesoMeta: z.number(), + pesoReal: z.number(), + fatorMeta: z.number(), + fatorReal: z.number(), + pct: z.number(), // % de valor (real/meta) — base da barra de progresso + falta: z.number(), // valor faltante p/ a meta +}); +export type MetaItem = z.infer<typeof MetaItemSchema>; + export const RepDashboardSchema = z.object({ meta: z.object({ atingido: z.number(), @@ -18,6 +43,9 @@ export const RepDashboardSchema = z.object({ pct: z.number(), falta: z.number(), }), + // Dimensão detectada do ERP e detalhamento por grupo (vazio quando global). + metaDimensao: MetaDimensaoSchema.default('global'), + metasPorGrupo: z.array(MetaItemSchema).default([]), comissao: z.object({ fixa: z.number(), flex: z.number(), @@ -32,6 +60,7 @@ export type RepDashboard = z.infer<typeof RepDashboardSchema>; export const RepInativosSummarySchema = z.object({ codVendedor: z.number().int(), + nomeVendedor: z.string().nullable().optional(), inativosCount: z.number().int(), }); export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>; diff --git a/libs/shared/api-interface/src/lib/order.contract.ts b/libs/shared/api-interface/src/lib/order.contract.ts index 921a5e7..3c67e09 100644 --- a/libs/shared/api-interface/src/lib/order.contract.ts +++ b/libs/shared/api-interface/src/lib/order.contract.ts @@ -6,13 +6,24 @@ import { z } from 'zod'; // ─── Situa ──────────────────────────────────────────────────────────────────── -// situa: 1=Pendente 2=Aprovado 3=Cancelado 4=Faturado -export const SituaPedidoSchema = z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]); +// Ciclo de vida do pedido SAR: +// 0=Orçamento → 1=Ag. Aprovação (se desconto > alçada) → 2=Transmitido +// Estados que o SAR controla: Orçamento e Transmitido (1 é o gate de desconto). +// Após Transmitido, o status passa a refletir o ERP (Emitido/Cancelado/Aguardando…) +// — espelhado quando a integração existir. +export const SituaPedidoSchema = z.union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), +]); export type SituaPedido = z.infer<typeof SituaPedidoSchema>; export const SITUA_LABEL: Record<number, string> = { + 0: 'Orçamento', 1: 'Ag. Aprovação', - 2: 'Aprovado', + 2: 'Transmitido', 3: 'Cancelado', 4: 'Faturado', }; @@ -54,6 +65,7 @@ export const PedidoSummarySchema = z.object({ nomeCliente: z.string().nullable().optional(), razaoCliente: z.string().nullable().optional(), codVendedor: z.number().int(), + nomeVendedor: z.string().nullable().optional(), situa: z.number().int(), statusDescr: z.string().optional(), // descrição legível do status dtPedido: z.string(), diff --git a/libs/shared/api-interface/src/lib/product.contract.ts b/libs/shared/api-interface/src/lib/product.contract.ts index 3afad65..ce46ba0 100644 --- a/libs/shared/api-interface/src/lib/product.contract.ts +++ b/libs/shared/api-interface/src/lib/product.contract.ts @@ -28,6 +28,16 @@ export const PautaSchema = z.object({ descricao: z.string(), }); export type Pauta = z.infer<typeof PautaSchema>; + +// ─── Forma de Pagamento (vw_formas_pagamento) ───────────────────────────────── + +export const FormaPagamentoSchema = z.object({ + codigo: z.number().int(), + descricao: z.string(), + numParcelas: z.number().int().nullable(), + txAcrescimo: z.string(), +}); +export type FormaPagamento = z.infer<typeof FormaPagamentoSchema>; export type ProdutoSummary = z.infer<typeof ProdutoSummarySchema>; // ─── Produto Detail ───────────────────────────────────────────────────────────