feat(mvp-rep): formas de pagamento do ERP + suporte offline completo
Formas de pagamento: - Endpoint GET /catalog/payment-methods lendo vw_formas_pagamento filtrado por ativa=1 e integrar_sar=1 - FormaPagamento schema/type no shared api-interface - Hook useFormasPagamento (staleTime 1h) substituindo lista hardcoded Offline (FR-4.2 / NFR-2.1–2.4): - IndexedDB queue: lib/offline/idb.ts + order-queue.ts sem deps externos - NewOrderPage detecta !navigator.onLine → enqueueOrder() → toast + reset - useOfflineSync: auto-sync ao reconectar (POST orders + PATCH transmit) - usePendingOrders: fila reativa via CustomEvents - AppShell: banner offline + useOfflineSync() global - OrdersPage: seção de pedidos pendentes com retry/descartar - sw.js: network-first para API GETs cacheáveis + stale-while-revalidate para assets + app shell navigate fallback Docs: - architecture.md: documento de decisões de arquitetura do SAR MVP Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
348
_bmad-output/planning-artifacts/architecture.md
Normal file
348
_bmad-output/planning-artifacts/architecture.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
---
|
||||||
|
stepsCompleted: [1, 2, 3, 4, 5]
|
||||||
|
inputDocuments:
|
||||||
|
- design-artifacts/A-Product-Brief/01-product-brief.md
|
||||||
|
- _bmad-output/planning-artifacts/prds/prd-sar-2026-05-27/prd.md
|
||||||
|
- design-artifacts/C-UX-Scenarios/00-ux-scenarios.md
|
||||||
|
- design-artifacts/_progress/wds-project-outline.yaml
|
||||||
|
workflowType: 'architecture'
|
||||||
|
project_name: 'SAR — Força de Vendas'
|
||||||
|
user_name: 'Julian'
|
||||||
|
date: '2026-05-30'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documento de Decisões de Arquitetura
|
||||||
|
# SAR — Força de Vendas
|
||||||
|
|
||||||
|
> Referência canônica para agentes de IA e devs implementando histórias. Cada decisão aqui é lei — não reabrir sem RFC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visão geral do sistema
|
||||||
|
|
||||||
|
SAR é um SaaS B2B de força de vendas multi-tenant. Arquitetura web-first com PWA para o cockpit Rep (mobile), desktop para Supervisor/Dono. MVP entrega cockpits Rafael (Rep) e Sandra (Supervisor).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser (PWA/Desktop) │
|
||||||
|
│ React 19.2 + TanStack Router/Query + Zustand + AntD │
|
||||||
|
└──────────────────────┬──────────────────────────────────┘
|
||||||
|
│ REST /api/v1 (+WebSocket/SSE futuro)
|
||||||
|
┌──────────────────────▼──────────────────────────────────┐
|
||||||
|
│ NestJS 11 API (apps/api) │
|
||||||
|
│ JwtAuthGuard → WorkspaceCLS → Module handlers │
|
||||||
|
└──────────────────────┬──────────────────────────────────┘
|
||||||
|
│ Prisma 7 (pool por empresa)
|
||||||
|
┌──────────────────────▼──────────────────────────────────┐
|
||||||
|
│ PostgreSQL 18 (schema "sar" + views ERP) │
|
||||||
|
│ Um schema por empresa (idEmpresa) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Estrutura do monorepo
|
||||||
|
|
||||||
|
```
|
||||||
|
sar/
|
||||||
|
├── apps/
|
||||||
|
│ ├── api/ ← NestJS (backend)
|
||||||
|
│ └── web/ ← React Vite (frontend)
|
||||||
|
├── libs/
|
||||||
|
│ └── shared/
|
||||||
|
│ └── api-interface/ ← contratos Zod compartilhados
|
||||||
|
├── STACK.md ← fonte da verdade técnica
|
||||||
|
└── CODING-RULES.md ← invariantes e pegadinhas
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regra:** Qualquer tipo que cruza a fronteira API↔Web mora em `libs/shared/api-interface`. Nunca duplicar tipos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Multi-tenancy
|
||||||
|
|
||||||
|
**Modelo:** `idEmpresa: number` (inteiro do ERP) identifica o tenant. Não existe `workspaceId: string` — o ADR 0006 original foi revogado.
|
||||||
|
|
||||||
|
**Mecanismo no backend:**
|
||||||
|
- `WorkspaceModule` registra CLS global (nestjs-cls)
|
||||||
|
- CLS middleware injeta `requestId` e `idEmpresa = 0` (fallback para rotas públicas)
|
||||||
|
- `JwtAuthGuard` após validar o JWT sobrescreve `idEmpresa`, `userId`, `role` e injeta o `PrismaClient` certo no CLS
|
||||||
|
- Todo handler acessa `cls.get('prisma')` — nunca um Prisma singleton (CODING-RULES PGD-DB-009)
|
||||||
|
|
||||||
|
**No banco:**
|
||||||
|
- Schema `sar` contém todas as tabelas SAR
|
||||||
|
- Views `vw_*` expõem dados do ERP legado (leitura apenas)
|
||||||
|
- Isolation por `id_empresa` nas queries — nenhuma query atravessa empresas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Módulos da API (NestJS)
|
||||||
|
|
||||||
|
| Módulo | Responsabilidade |
|
||||||
|
|--------|-----------------|
|
||||||
|
| `auth` | `/auth/me` — perfil do usuário autenticado; `/dev-auth` — login dev (sem master-login) |
|
||||||
|
| `workspace` | CLS global, pool de Prisma por empresa |
|
||||||
|
| `catalog` | Catálogo de produtos, empresa (pauta/preço) |
|
||||||
|
| `clients` | Lista e ficha de clientes |
|
||||||
|
| `orders` | CRUD pedidos, fluxo aprovação, histórico |
|
||||||
|
| `dashboard` | KPIs do Rep e Supervisor |
|
||||||
|
| `notifications` | Web Push (futuro SSE/Socket.IO) |
|
||||||
|
| `health` | `/health` — liveness/readiness |
|
||||||
|
| `ping` | `/ping` — sanity check |
|
||||||
|
| `logger` | Pino configurado com redact de PII |
|
||||||
|
|
||||||
|
**Padrão de módulo:**
|
||||||
|
```
|
||||||
|
módulo/
|
||||||
|
├── *.module.ts
|
||||||
|
├── *.controller.ts ← valida entrada com contrato Zod
|
||||||
|
├── *.service.ts ← lógica de domínio
|
||||||
|
└── *.types.ts ← tipos internos (não exportados para o shared)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Contratos Zod (API Interface)
|
||||||
|
|
||||||
|
Localização: `libs/shared/api-interface/src/lib/*.contract.ts`
|
||||||
|
|
||||||
|
Contratos existentes: `auth`, `client`, `company`, `dashboard`, `notifications`, `order`, `ping`, `product`
|
||||||
|
|
||||||
|
**Padrão de contrato:**
|
||||||
|
```typescript
|
||||||
|
// Schema de entrada (mutation)
|
||||||
|
export const CreateXxxSchema = z.object({ ... });
|
||||||
|
export type CreateXxx = z.infer<typeof CreateXxxSchema>;
|
||||||
|
|
||||||
|
// Schema de saída (query)
|
||||||
|
export const XxxResponseSchema = z.object({ ... });
|
||||||
|
export type XxxResponse = z.infer<typeof XxxResponseSchema>;
|
||||||
|
|
||||||
|
// Schema de lista (com paginação)
|
||||||
|
export const XxxListQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
});
|
||||||
|
export const XxxListResponseSchema = z.object({
|
||||||
|
data: z.array(XxxResponseSchema),
|
||||||
|
total: z.number().int().nonneg(),
|
||||||
|
page: z.number().int().positive(),
|
||||||
|
limit: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regra:** Backend valida entrada com o Schema (422 se inválido — RFC 9457). Frontend parseia resposta com o Schema (`Schema.parse(data)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Autenticação e autorização
|
||||||
|
|
||||||
|
**Fluxo MVP (dev):**
|
||||||
|
- Token JWT em `localStorage` via `authStore` (transitório — dev only)
|
||||||
|
- `POST /api/v1/auth/dev-login` retorna JWT assinado
|
||||||
|
- Bearer token no header de toda requisição protegida
|
||||||
|
|
||||||
|
**Fluxo produção (planejado):**
|
||||||
|
- master-login IdP OAuth2/OIDC (JCS próprio)
|
||||||
|
- Access token em memória; refresh token em cookie `httpOnly; Secure; SameSite=Lax`
|
||||||
|
- `JwtAuthGuard` valida e injeta contexto no CLS
|
||||||
|
|
||||||
|
**Papéis (roles):** `rep` · `supervisor` · `dono` · `admin`
|
||||||
|
|
||||||
|
**Frontend — role routing:**
|
||||||
|
- Raiz `/` → lê role do JWT payload, redireciona para `<RepPainel>` ou `<SupervisorPainel>`
|
||||||
|
- Cada cockpit renderiza apenas suas rotas; sem RBAC granular no MVP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Frontend — estrutura
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/src/
|
||||||
|
├── cockpits/
|
||||||
|
│ ├── rep/ ← páginas do cockpit Rafael
|
||||||
|
│ │ ├── RepPainel.tsx
|
||||||
|
│ │ ├── ClientsPage.tsx
|
||||||
|
│ │ ├── ClientDetailPage.tsx
|
||||||
|
│ │ ├── OrdersPage.tsx
|
||||||
|
│ │ ├── OrderDetailPage.tsx
|
||||||
|
│ │ ├── OrderPrintPage.tsx
|
||||||
|
│ │ ├── NewOrderPage.tsx
|
||||||
|
│ │ └── CatalogPage.tsx
|
||||||
|
│ └── supervisor/ ← páginas do cockpit Sandra
|
||||||
|
│ ├── SupervisorPainel.tsx
|
||||||
|
│ └── ApprovalQueuePage.tsx
|
||||||
|
├── components/
|
||||||
|
│ └── layout/ ← AppShell, Sidebar, Topbar
|
||||||
|
├── lib/
|
||||||
|
│ ├── router.tsx ← TanStack Router (flat routes)
|
||||||
|
│ ├── auth-store.ts ← Zustand-lite para token
|
||||||
|
│ ├── api-client.ts ← apiFetch + ApiError (RFC 9457)
|
||||||
|
│ ├── query-client.ts← TanStack Query config
|
||||||
|
│ ├── queries/ ← hooks de query por domínio
|
||||||
|
│ ├── hooks/ ← hooks utilitários
|
||||||
|
│ └── theme.ts ← AntD theme config
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rotas atuais:**
|
||||||
|
|
||||||
|
| Path | Componente | Cockpit |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| `/` | HomeRoute (role redirect) | — |
|
||||||
|
| `/rep` | RepPainel | Rep |
|
||||||
|
| `/clientes` | ClientsPage | Rep |
|
||||||
|
| `/clientes/$id` | ClientDetailPage | Rep |
|
||||||
|
| `/pedidos` | OrdersPage | Rep |
|
||||||
|
| `/pedidos/novo` | NewOrderPage | Rep |
|
||||||
|
| `/pedidos/$id` | OrderDetailPage | Rep |
|
||||||
|
| `/pedidos/$id/imprimir` | OrderPrintPage | Rep |
|
||||||
|
| `/catalogo` | CatalogPage | Rep |
|
||||||
|
| `/aprovacoes` | ApprovalQueuePage | Supervisor |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Gerenciamento de estado
|
||||||
|
|
||||||
|
| Tipo de estado | Solução |
|
||||||
|
|----------------|---------|
|
||||||
|
| Server state (queries, mutations) | TanStack Query v5 |
|
||||||
|
| Auth (token, user profile) | Zustand (`authStore`) |
|
||||||
|
| UI state local (modais, forms) | `useState` / `useReducer` em componente |
|
||||||
|
| Offline queue (futuro) | IndexedDB + Service Worker |
|
||||||
|
|
||||||
|
**Regra:** Não duplicar server state em Zustand. TanStack Query é a fonte da verdade para dados do servidor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API Client (frontend)
|
||||||
|
|
||||||
|
`lib/api-client.ts` — `apiFetch(path, options)`:
|
||||||
|
- Base URL: `/api/v1` (proxy Vite em dev → `:3000`; Nginx em prod → mesmo origin)
|
||||||
|
- Injeta `Authorization: Bearer <token>` automaticamente
|
||||||
|
- Parseia `application/problem+json` → `ApiError` com `status` + `problem`
|
||||||
|
- Não faz parse Zod — caller é responsável
|
||||||
|
|
||||||
|
**Tratamento de erro:**
|
||||||
|
- `422` = validação Zod (erros detalhados em `problem.errors`)
|
||||||
|
- `4xx` outros = erro de domínio
|
||||||
|
- `5xx` = retry automático pelo QueryClient (máx 2x)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Erros e RFC 9457
|
||||||
|
|
||||||
|
Todo erro da API retorna `application/problem+json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "https://sar.jcsinformatica.com.br/errors/validation-error",
|
||||||
|
"title": "Validation Error",
|
||||||
|
"status": 422,
|
||||||
|
"detail": "Campo obrigatório ausente",
|
||||||
|
"requestId": "uuid",
|
||||||
|
"errors": [{ "path": "idCliente", "message": "Required", "code": "invalid_type" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regra (CODING-RULES):** Nunca retornar `403` para recurso inexistente — usar `404` para não vazar existência.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Ciclo de vida do Pedido
|
||||||
|
|
||||||
|
Estados controlados pelo SAR:
|
||||||
|
|
||||||
|
```
|
||||||
|
0 (Orçamento) ──→ 1 (Ag. Aprovação) ──→ 2 (Transmitido)
|
||||||
|
│
|
||||||
|
└──→ 0 (recusado → volta Orçamento)
|
||||||
|
```
|
||||||
|
|
||||||
|
Estados espelhados do ERP (pós-integração): `3=Cancelado`, `4=Faturado`
|
||||||
|
|
||||||
|
**Alçada:** se `descontoPerc > alçadaRep` → situa vai para `1`. Supervisor aprova/recusa/ajusta.
|
||||||
|
|
||||||
|
**Idempotency-Key:** gerado no frontend antes do envio; garante que retentativas de sync não duplicam pedidos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Impressão de pedido
|
||||||
|
|
||||||
|
Rota `/pedidos/$id/imprimir` → `OrderPrintPage.tsx` — renderiza HTML otimizado para `window.print()` (browser → PDF). Sem geração server-side de PDF no MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Offline (Rafael) — planejado
|
||||||
|
|
||||||
|
**Mecanismo:** IndexedDB queue + Service Worker
|
||||||
|
- Catálogo e clientes cacheados com TTL 4h
|
||||||
|
- Pedidos criados offline enfileirados com `Idempotency-Key` local
|
||||||
|
- Sync automático ao retorno de sinal
|
||||||
|
- Dados financeiros sensíveis (limite de crédito numérico, inadimplência) só online
|
||||||
|
|
||||||
|
**Status atual:** não implementado. Será uma epic dedicada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Real-time (Sandra) — planejado
|
||||||
|
|
||||||
|
**Mecanismo:** Socket.IO 4 / SSE via `notifications` module
|
||||||
|
- Novos pedidos aparecem em < 3s no painel Sandra
|
||||||
|
- Push notifications Web Push (PWA) para aprovações
|
||||||
|
|
||||||
|
**Status atual:** `notifications.module.ts` existe, endpoints WIP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Banco de dados
|
||||||
|
|
||||||
|
- PostgreSQL 18, schema `sar`
|
||||||
|
- Prisma 7 como ORM/query builder
|
||||||
|
- Pool de clientes Prisma gerenciado pelo `WorkspacePrismaPool` (CLS)
|
||||||
|
- Views `vw_clientes`, `vw_representantes`, `vw_pedidos_erp` expõem dados legados
|
||||||
|
- Isolation por `id_empresa` em todas as queries
|
||||||
|
|
||||||
|
**Regra:** Raw SQL via `prisma.$queryRaw` apenas quando Prisma ORM não atende (ex: JOINs com views legadas).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Infraestrutura
|
||||||
|
|
||||||
|
| Componente | Solução |
|
||||||
|
|-----------|---------|
|
||||||
|
| Hosting | Proxmox on-prem Brasil |
|
||||||
|
| Orquestração | Docker Compose |
|
||||||
|
| Deploy | Ansible |
|
||||||
|
| CDN / proxy | Cloudflare + Nginx |
|
||||||
|
| Object storage | MinIO |
|
||||||
|
| Secrets | Vault |
|
||||||
|
| Cache / filas | Valkey (Redis-compatible) + BullMQ 5.77 |
|
||||||
|
| Observabilidade | OpenTelemetry + Pino |
|
||||||
|
| E-mail | Resend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Invariantes de implementação (CODING-RULES)
|
||||||
|
|
||||||
|
- `PrismaClient` sempre via `cls.get('prisma')` — nunca singleton injetado diretamente
|
||||||
|
- `idEmpresa` real vem do JWT (guard) — nunca de `.env` ou parâmetro de rota
|
||||||
|
- PII (CPF/CNPJ, telefone, e-mail) redactada em todos os logs via Pino `redact`
|
||||||
|
- Tokens JWT: acesso em memória, refresh em cookie `httpOnly; Secure; SameSite=Lax`
|
||||||
|
- Rate limit em auth: 5 tentativas/min/IP
|
||||||
|
- Nomes visíveis em toda UI — nunca exibir só código (cliente, rep, produto)
|
||||||
|
- Empresa matriz `9001` normalizada → `1` em consultas de catálogo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Decisões em aberto (não resolver sem RFC)
|
||||||
|
|
||||||
|
| # | Questão |
|
||||||
|
|---|---------|
|
||||||
|
| OQ-1 | Formato e frequência de importação do catálogo/clientes do ERP legado |
|
||||||
|
| OQ-2 | Alçada de desconto: fixa por rep ou por linha de produto? |
|
||||||
|
| OQ-3 | Fórmula de cálculo de comissão FLEX documentada? |
|
||||||
|
| OQ-5 | Múltiplos supervisores: distribuição da fila de Aprovações |
|
||||||
|
| OQ-6 | TTL de 4h para catálogo offline é aceitável para o 1º cliente? |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Gerado em 2026-05-30. Baseado no código existente + PRD 2026-05-27 + Product Brief 2026-05-26.*
|
||||||
@@ -15,11 +15,11 @@ export class AuthController {
|
|||||||
const role = this.cls.get('role') ?? 'rep';
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
// Representante é cadastro global (sem id_empresa).
|
||||||
const rows = await prisma.$queryRaw<{ codigo: number; nome: string }[]>`
|
const rows = await prisma.$queryRaw<{ codigo: number; nome: string }[]>`
|
||||||
SELECT codigo, nome
|
SELECT codigo, nome
|
||||||
FROM sar.vw_representantes
|
FROM sar.vw_representantes
|
||||||
WHERE codigo = ${parseInt(userId, 10)}
|
WHERE codigo = ${parseInt(userId, 10)}
|
||||||
AND id_empresa = ${idEmpresa}
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
ProdutoListQuerySchema,
|
ProdutoListQuerySchema,
|
||||||
|
type EmpresaInfo,
|
||||||
|
type FormaPagamento,
|
||||||
type Pauta,
|
type Pauta,
|
||||||
type ProdutoDetail,
|
type ProdutoDetail,
|
||||||
type ProdutoListQuery,
|
type ProdutoListQuery,
|
||||||
@@ -22,6 +24,16 @@ export class CatalogController {
|
|||||||
return this.catalog.pautas();
|
return this.catalog.pautas();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('payment-methods')
|
||||||
|
formasPagamento(): Promise<FormaPagamento[]> {
|
||||||
|
return this.catalog.formasPagamento();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('company')
|
||||||
|
company(): Promise<EmpresaInfo> {
|
||||||
|
return this.catalog.company();
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
|
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
|
||||||
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;
|
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type {
|
import type {
|
||||||
|
EmpresaInfo,
|
||||||
|
FormaPagamento,
|
||||||
Pauta,
|
Pauta,
|
||||||
ProdutoDetail,
|
ProdutoDetail,
|
||||||
ProdutoListQuery,
|
ProdutoListQuery,
|
||||||
@@ -16,6 +18,14 @@ function escSql(s: string): string {
|
|||||||
return s.replace(/'/g, "''");
|
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 {
|
interface ProdutoRow {
|
||||||
id_erp: number;
|
id_erp: number;
|
||||||
codigo: string;
|
codigo: string;
|
||||||
@@ -45,10 +55,77 @@ interface ProdutoRow {
|
|||||||
export class CatalogService {
|
export class CatalogService {
|
||||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
// Dados legais da empresa matriz que fatura o pedido (cabeçalho do PDF).
|
||||||
|
async company(): Promise<EmpresaInfo> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id_empresa: number;
|
||||||
|
razao_social: string | null;
|
||||||
|
nome_fantasia: string | null;
|
||||||
|
cnpj: string | null;
|
||||||
|
inscr_estadual: string | null;
|
||||||
|
endereco: string | null;
|
||||||
|
numero: string | null;
|
||||||
|
complemento: string | null;
|
||||||
|
bairro: string | null;
|
||||||
|
cidade: string | null;
|
||||||
|
uf: string | null;
|
||||||
|
cep: string | null;
|
||||||
|
telefone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT e.id_empresa,
|
||||||
|
TRIM(e.razao_social) AS razao_social,
|
||||||
|
TRIM(e.nome) AS nome_fantasia,
|
||||||
|
TRIM(e.cnpj) AS cnpj,
|
||||||
|
TRIM(e.inscr_estadual) AS inscr_estadual,
|
||||||
|
TRIM(e.endereco) AS endereco,
|
||||||
|
NULLIF(e.numero, 0)::text AS numero,
|
||||||
|
NULLIF(TRIM(e.complemento), '.') AS complemento,
|
||||||
|
TRIM(e.bairro) AS bairro,
|
||||||
|
TRIM(m.nome) AS cidade,
|
||||||
|
TRIM(e.estado::text) AS uf,
|
||||||
|
TRIM(e.cep::text) AS cep,
|
||||||
|
TRIM(e.telefone::text) AS telefone,
|
||||||
|
TRIM(e.email) AS email
|
||||||
|
FROM gestao.empresa e
|
||||||
|
LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio
|
||||||
|
WHERE e.id_empresa = ${idEmpresa}
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
const r = rows[0];
|
||||||
|
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`);
|
||||||
|
|
||||||
|
const clean = (v: string | null) => {
|
||||||
|
const t = (v ?? '').trim();
|
||||||
|
return t === '' ? null : t;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
idEmpresa: Number(r.id_empresa),
|
||||||
|
razaoSocial: clean(r.razao_social) ?? clean(r.nome_fantasia) ?? `Empresa ${r.id_empresa}`,
|
||||||
|
nomeFantasia: clean(r.nome_fantasia),
|
||||||
|
cnpj: clean(r.cnpj),
|
||||||
|
inscricaoEstadual: clean(r.inscr_estadual),
|
||||||
|
endereco: clean(r.endereco),
|
||||||
|
numero: clean(r.numero),
|
||||||
|
complemento: clean(r.complemento),
|
||||||
|
bairro: clean(r.bairro),
|
||||||
|
cidade: clean(r.cidade),
|
||||||
|
uf: clean(r.uf),
|
||||||
|
cep: clean(r.cep),
|
||||||
|
telefone: clean(r.telefone),
|
||||||
|
email: clean(r.email),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async pautas(): Promise<Pauta[]> {
|
async pautas(): Promise<Pauta[]> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
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 userId = this.cls.get('userId');
|
||||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
@@ -60,8 +137,7 @@ export class CatalogService {
|
|||||||
const rows = await prisma.$queryRawUnsafe<PautaRow[]>(`
|
const rows = await prisma.$queryRawUnsafe<PautaRow[]>(`
|
||||||
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
|
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
|
||||||
FROM vw_pautas pa
|
FROM vw_pautas pa
|
||||||
JOIN vw_representantes r ON r.id_empresa = pa.id_empresa
|
JOIN vw_representantes r ON pa.codigo IN (
|
||||||
AND pa.codigo IN (
|
|
||||||
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||||
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||||
)
|
)
|
||||||
@@ -81,7 +157,7 @@ export class CatalogService {
|
|||||||
async list(query: ProdutoListQuery): Promise<ProdutoListResponse> {
|
async list(query: ProdutoListQuery): Promise<ProdutoListResponse> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
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 { q, codGrupo, idPauta, page, limit } = query;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
@@ -183,7 +259,7 @@ export class CatalogService {
|
|||||||
async findOne(idErp: number): Promise<ProdutoDetail | null> {
|
async findOne(idErp: number): Promise<ProdutoDetail | null> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
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<ProdutoRow[]>(`
|
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -232,4 +308,33 @@ export class CatalogService {
|
|||||||
precoPromocional: p.preco_promocional,
|
precoPromocional: p.preco_promocional,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async formasPagamento(): Promise<FormaPagamento[]> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
codigo: number;
|
||||||
|
descricao: string;
|
||||||
|
num_parcelas: number | null;
|
||||||
|
tx_acrescimo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT codigo, TRIM(descricao) AS descricao, num_parcelas, tx_acrescimo::text
|
||||||
|
FROM sar.vw_formas_pagamento
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND ativa = 1
|
||||||
|
AND integrar_sar = 1
|
||||||
|
ORDER BY codigo
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
codigo: Number(r.codigo),
|
||||||
|
descricao: r.descricao,
|
||||||
|
numParcelas: r.num_parcelas !== null ? Number(r.num_parcelas) : null,
|
||||||
|
txAcrescimo: r.tx_acrescimo ?? '0',
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface ClientRow {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
telefone: string | null;
|
telefone: string | null;
|
||||||
cod_vendedor: number;
|
cod_vendedor: number;
|
||||||
|
nome_vendedor: string | null;
|
||||||
limite_credito: string | null;
|
limite_credito: string | null;
|
||||||
dt_ultima_compra: Date | null;
|
dt_ultima_compra: Date | null;
|
||||||
ativo: number;
|
ativo: number;
|
||||||
@@ -55,20 +56,33 @@ interface ClientRow {
|
|||||||
// SQL compartilhado: dois subqueries que calculam a data do último pedido
|
// SQL compartilhado: dois subqueries que calculam a data do último pedido
|
||||||
// considerando TANTO pedidos ERP (vw_pedidos_erp) QUANTO pedidos SAR (tabela pedidos).
|
// 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).
|
// 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 (
|
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
|
FROM vw_pedidos_erp
|
||||||
WHERE situa NOT IN (5)
|
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
|
||||||
GROUP BY id_cliente, id_empresa
|
GROUP BY id_cliente
|
||||||
) erp_ped ON erp_ped.id_cliente = c.id_cliente AND erp_ped.id_empresa = c.id_empresa
|
) erp_ped ON erp_ped.id_cliente = c.id_cliente
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT id_cliente, id_empresa, MAX(dt_pedido) AS dt_max
|
SELECT id_cliente, MAX(dt_pedido) AS dt_max
|
||||||
FROM pedidos
|
FROM pedidos
|
||||||
WHERE situa != 3
|
WHERE situa != 3 AND id_empresa = ${idEmpresa}
|
||||||
GROUP BY id_cliente, id_empresa
|
GROUP BY id_cliente
|
||||||
) sar_ped ON sar_ped.id_cliente = c.id_cliente AND sar_ped.id_empresa = c.id_empresa
|
) 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.
|
// 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') => `
|
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
|
// Filtro de status calculado em SQL — evita paginação quebrada do filtro pós-SQL
|
||||||
const statusFilter = status ? `AND ${ACTIVITY_CASE()} = '${status}'` : '';
|
const statusFilter = status ? `AND ${ACTIVITY_CASE()} = '${status}'` : '';
|
||||||
|
|
||||||
|
// Clientes globais: sem filtro de id_empresa. Rep continua escopado por cod_vendedor.
|
||||||
const baseWhere = `
|
const baseWhere = `
|
||||||
WHERE c.id_empresa = ${idEmpresa}
|
WHERE c.ativo = 1
|
||||||
AND c.ativo = 1
|
|
||||||
${vendedorFilter}
|
${vendedorFilter}
|
||||||
${searchFilter}
|
${searchFilter}
|
||||||
${statusFilter}
|
${statusFilter}
|
||||||
`;
|
`;
|
||||||
|
const joins = pedidosJoins(idEmpresa);
|
||||||
|
|
||||||
const [rows, totalRows] = await Promise.all([
|
const [rows, totalRows] = await Promise.all([
|
||||||
prisma.$queryRawUnsafe<ClientRow[]>(`
|
prisma.$queryRawUnsafe<ClientRow[]>(`
|
||||||
@@ -123,6 +138,7 @@ export class ClientsService {
|
|||||||
c.email,
|
c.email,
|
||||||
c.telefone,
|
c.telefone,
|
||||||
c.cod_vendedor,
|
c.cod_vendedor,
|
||||||
|
${NOME_VENDEDOR_SUBQ},
|
||||||
c.limite_credito::text,
|
c.limite_credito::text,
|
||||||
c.ativo,
|
c.ativo,
|
||||||
c.pessoa,
|
c.pessoa,
|
||||||
@@ -138,7 +154,7 @@ export class ClientsService {
|
|||||||
c.dt_atual::text,
|
c.dt_atual::text,
|
||||||
GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra
|
GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra
|
||||||
FROM vw_clientes c
|
FROM vw_clientes c
|
||||||
${PEDIDOS_JOINS}
|
${joins}
|
||||||
${baseWhere}
|
${baseWhere}
|
||||||
ORDER BY c.nome
|
ORDER BY c.nome
|
||||||
LIMIT ${limit} OFFSET ${offset}
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
@@ -146,7 +162,7 @@ export class ClientsService {
|
|||||||
prisma.$queryRawUnsafe<[{ count: string }]>(`
|
prisma.$queryRawUnsafe<[{ count: string }]>(`
|
||||||
SELECT COUNT(*)::text AS count
|
SELECT COUNT(*)::text AS count
|
||||||
FROM vw_clientes c
|
FROM vw_clientes c
|
||||||
${PEDIDOS_JOINS}
|
${joins}
|
||||||
${baseWhere}
|
${baseWhere}
|
||||||
`),
|
`),
|
||||||
]);
|
]);
|
||||||
@@ -162,6 +178,7 @@ export class ClientsService {
|
|||||||
email: r.email,
|
email: r.email,
|
||||||
telefone: r.telefone,
|
telefone: r.telefone,
|
||||||
codVendedor: Number(r.cod_vendedor),
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome_vendedor ?? null,
|
||||||
limiteCreditoStr: r.limite_credito,
|
limiteCreditoStr: r.limite_credito,
|
||||||
activityStatus: activityStatus(r.dt_ultima_compra),
|
activityStatus: activityStatus(r.dt_ultima_compra),
|
||||||
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
|
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
|
||||||
@@ -178,14 +195,14 @@ export class ClientsService {
|
|||||||
const rows = await prisma.$queryRawUnsafe<ClientRow[]>(`
|
const rows = await prisma.$queryRawUnsafe<ClientRow[]>(`
|
||||||
SELECT
|
SELECT
|
||||||
c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email,
|
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.ativo, c.pessoa, c.inscricao_estadual, c.endereco, c.num_endereco,
|
||||||
c.bairro, c.cep, c.ddd, c.obs, c.cod_pauta,
|
c.bairro, c.cep, c.ddd, c.obs, c.cod_pauta,
|
||||||
c.dt_cadastro::text, c.dt_atual::text,
|
c.dt_cadastro::text, c.dt_atual::text,
|
||||||
GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra
|
GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra
|
||||||
FROM vw_clientes c
|
FROM vw_clientes c
|
||||||
${PEDIDOS_JOINS}
|
${pedidosJoins(idEmpresa)}
|
||||||
WHERE c.id_empresa = ${idEmpresa} AND c.id_cliente = ${idCliente}
|
WHERE c.id_cliente = ${idCliente}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -201,6 +218,7 @@ export class ClientsService {
|
|||||||
email: r.email,
|
email: r.email,
|
||||||
telefone: r.telefone,
|
telefone: r.telefone,
|
||||||
codVendedor: Number(r.cod_vendedor),
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome_vendedor ?? null,
|
||||||
limiteCreditoStr: r.limite_credito,
|
limiteCreditoStr: r.limite_credito,
|
||||||
activityStatus: activityStatus(r.dt_ultima_compra),
|
activityStatus: activityStatus(r.dt_ultima_compra),
|
||||||
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
|
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
|
||||||
|
|||||||
@@ -7,11 +7,32 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
|||||||
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
|
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
|
||||||
const SITUA_PENDENTE = 1;
|
const SITUA_PENDENTE = 1;
|
||||||
|
|
||||||
// tipo='G' em gestao.metavenda = meta geral de valor do mês
|
// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
|
||||||
const TIPO_META_GERAL = 'G';
|
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;
|
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 {
|
interface RepRow {
|
||||||
@@ -28,6 +49,7 @@ interface InativoRow {
|
|||||||
|
|
||||||
interface InativosPorRepRow {
|
interface InativosPorRepRow {
|
||||||
cod_vendedor: number;
|
cod_vendedor: number;
|
||||||
|
nome_vendedor: string | null;
|
||||||
inativos_count: string;
|
inativos_count: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,25 +70,33 @@ export class DashboardService {
|
|||||||
const monthStart = new Date(year, month - 1, 1);
|
const monthStart = new Date(year, month - 1, 1);
|
||||||
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
|
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'
|
// 1. Metas do mês — vw_metas vive na matriz (normaliza 9001→1).
|
||||||
const metaRows = await prisma.$queryRawUnsafe<MetaRow[]>(`
|
// GL = meta global; GR = meta por grupo. A dimensão segue o que o ERP tiver.
|
||||||
SELECT valor::text
|
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||||
|
const metaErpRows = await prisma.$queryRawUnsafe<MetaErpRow[]>(`
|
||||||
|
SELECT TRIM(tipo) AS tipo, cod_grupo, TRIM(desc_grupo) AS desc_grupo,
|
||||||
|
valor::text AS valor, qtdade::text AS qtdade,
|
||||||
|
peso::text AS peso, vl_fator::text AS vl_fator
|
||||||
FROM vw_metas
|
FROM vw_metas
|
||||||
WHERE id_empresa = ${idEmpresa}
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
AND cod_vendedor = ${codVendedor}
|
AND cod_vendedor = ${codVendedor}
|
||||||
AND TRIM(tipo) = '${TIPO_META_GERAL}'
|
|
||||||
AND ano = ${year}
|
AND ano = ${year}
|
||||||
AND mes = ${month}
|
AND mes = ${month}
|
||||||
LIMIT 1
|
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)
|
// 2. Taxas do representante — fonte: gestao.vendedor (via vw_representantes)
|
||||||
const repRows = await prisma.$queryRawUnsafe<RepRow[]>(`
|
const repRows = await prisma.$queryRawUnsafe<RepRow[]>(`
|
||||||
SELECT taxa_com::text, COALESCE(permitir_flex, 0) AS permitir_flex
|
SELECT taxa_com::text, COALESCE(permitir_flex, 0) AS permitir_flex
|
||||||
FROM vw_representantes
|
FROM vw_representantes
|
||||||
WHERE id_empresa = ${idEmpresa}
|
WHERE codigo = ${codVendedor}
|
||||||
AND codigo = ${codVendedor}
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`);
|
`);
|
||||||
const commissionRate = repRows[0] ? Number(repRows[0].taxa_com) : 3;
|
const commissionRate = repRows[0] ? Number(repRows[0].taxa_com) : 3;
|
||||||
@@ -79,7 +109,8 @@ export class DashboardService {
|
|||||||
});
|
});
|
||||||
const flexRate = flexOverride ? Number(flexOverride.taxaFlex) : 1;
|
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 monthStartStr = monthStart.toISOString().slice(0, 10);
|
||||||
const monthEndStr = monthEnd.toISOString().slice(0, 10);
|
const monthEndStr = monthEnd.toISOString().slice(0, 10);
|
||||||
|
|
||||||
@@ -94,7 +125,10 @@ export class DashboardService {
|
|||||||
num_ped_sar: string;
|
num_ped_sar: string;
|
||||||
numero: number;
|
numero: number;
|
||||||
id_cliente: number;
|
id_cliente: number;
|
||||||
|
nome_cliente: string | null;
|
||||||
|
razao_cliente: string | null;
|
||||||
cod_vendedor: number;
|
cod_vendedor: number;
|
||||||
|
nome_vendedor: string | null;
|
||||||
situa: number;
|
situa: number;
|
||||||
status_descr: string;
|
status_descr: string;
|
||||||
dt_pedido: Date;
|
dt_pedido: Date;
|
||||||
@@ -103,13 +137,13 @@ export class DashboardService {
|
|||||||
obs: string | null;
|
obs: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [atingidoRows, pedidosMesRows, recentRows] = await Promise.all([
|
const [atingidoRows, pedidosMesRows, recentRows, realizadoGrupoRows] = await Promise.all([
|
||||||
prisma.$queryRawUnsafe<TotalRow[]>(`
|
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||||
SELECT COALESCE(SUM(total), 0)::text AS total
|
SELECT COALESCE(SUM(total), 0)::text AS total
|
||||||
FROM vw_pedidos_erp
|
FROM vw_pedidos_erp
|
||||||
WHERE id_empresa = ${idEmpresa}
|
WHERE id_empresa = ${idEmpresa}
|
||||||
AND cod_vendedor = ${codVendedor}
|
AND cod_vendedor = ${codVendedor}
|
||||||
AND situa IN (2, 4)
|
AND situa NOT IN (1, 5)
|
||||||
AND dt_pedido >= '${monthStartStr}'
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
AND dt_pedido <= '${monthEndStr}'
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
`),
|
`),
|
||||||
@@ -123,16 +157,40 @@ export class DashboardService {
|
|||||||
AND dt_pedido <= '${monthEndStr}'
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
`),
|
`),
|
||||||
prisma.$queryRawUnsafe<RecentRow[]>(`
|
prisma.$queryRawUnsafe<RecentRow[]>(`
|
||||||
SELECT id_pedido, num_ped_sar, numero, id_cliente, cod_vendedor,
|
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
|
||||||
situa, status_descr, dt_pedido, total::text, desconto_perc::text, obs
|
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
|
||||||
FROM vw_pedidos_erp
|
c.nome AS nome_cliente, c.razao AS razao_cliente,
|
||||||
WHERE id_empresa = ${idEmpresa}
|
(SELECT r.nome FROM vw_representantes r
|
||||||
AND cod_vendedor = ${codVendedor}
|
WHERE r.codigo = e.cod_vendedor
|
||||||
AND situa != 5
|
LIMIT 1) AS nome_vendedor
|
||||||
AND dt_pedido >= '${new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}'
|
FROM vw_pedidos_erp e
|
||||||
ORDER BY dt_pedido DESC
|
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
|
LIMIT 10
|
||||||
`),
|
`),
|
||||||
|
// Realizado por grupo — itens faturados (situa 2/4) → produto (matriz) → cod_grupo.
|
||||||
|
// peso = qtd × peso_líquido do produto; pedidos = nº de pedidos distintos.
|
||||||
|
prisma.$queryRawUnsafe<RealizadoGrupoRow[]>(`
|
||||||
|
SELECT p.cod_grupo,
|
||||||
|
COALESCE(NULLIF(TRIM(p.grupo), ''), p.cod_grupo::text) AS grupo,
|
||||||
|
COUNT(DISTINCT pi.id_pedido)::text AS pedidos,
|
||||||
|
COALESCE(SUM(pi.total), 0)::text AS valor,
|
||||||
|
COALESCE(SUM(pi.qtd), 0)::text AS qtd,
|
||||||
|
COALESCE(SUM(pi.qtd * COALESCE(p.peso_liquido, 0)), 0)::text AS peso
|
||||||
|
FROM vw_peditens_erp pi
|
||||||
|
JOIN vw_pedidos_erp e ON e.id_pedido = pi.id_pedido
|
||||||
|
JOIN vw_produtos p ON p.id_erp = pi.id_produto AND p.id_empresa = ${idEmpresaMatriz}
|
||||||
|
WHERE e.id_empresa = ${idEmpresa}
|
||||||
|
AND e.cod_vendedor = ${codVendedor}
|
||||||
|
AND e.situa NOT IN (1, 5)
|
||||||
|
AND e.dt_pedido >= '${monthStartStr}'
|
||||||
|
AND e.dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY p.cod_grupo, grupo
|
||||||
|
`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const atingido = Number(atingidoRows[0]?.total ?? 0);
|
const atingido = Number(atingidoRows[0]?.total ?? 0);
|
||||||
@@ -157,10 +215,9 @@ export class DashboardService {
|
|||||||
FROM vw_clientes c
|
FROM vw_clientes c
|
||||||
LEFT JOIN vw_pedidos_erp p
|
LEFT JOIN vw_pedidos_erp p
|
||||||
ON p.id_cliente = c.id_cliente
|
ON p.id_cliente = c.id_cliente
|
||||||
AND p.id_empresa = c.id_empresa
|
AND p.id_empresa = ${idEmpresa}
|
||||||
AND p.situa != 5
|
AND p.situa != 5
|
||||||
WHERE c.id_empresa = ${idEmpresa}
|
WHERE c.cod_vendedor = ${codVendedor}
|
||||||
AND c.cod_vendedor = ${codVendedor}
|
|
||||||
AND c.ativo = 1
|
AND c.ativo = 1
|
||||||
GROUP BY c.id_cliente, c.nome
|
GROUP BY c.id_cliente, c.nome
|
||||||
HAVING MAX(p.dt_pedido) IS NULL
|
HAVING MAX(p.dt_pedido) IS NULL
|
||||||
@@ -169,8 +226,41 @@ export class DashboardService {
|
|||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo.
|
||||||
|
const realPorGrupo = new Map<number, RealizadoGrupoRow>();
|
||||||
|
for (const r of realizadoGrupoRows) {
|
||||||
|
if (r.cod_grupo != null) realPorGrupo.set(Number(r.cod_grupo), r);
|
||||||
|
}
|
||||||
|
const metasPorGrupo = grRows
|
||||||
|
.map((m) => {
|
||||||
|
const cod = m.cod_grupo != null ? Number(m.cod_grupo) : null;
|
||||||
|
const real = cod != null ? realPorGrupo.get(cod) : undefined;
|
||||||
|
const valorMeta = Number(m.valor);
|
||||||
|
const valorReal = real ? Number(real.valor) : 0;
|
||||||
|
const pesoMeta = Number(m.peso);
|
||||||
|
const pesoReal = real ? Number(real.peso) : 0;
|
||||||
|
return {
|
||||||
|
codigo: cod,
|
||||||
|
rotulo: m.desc_grupo || real?.grupo || `Grupo ${cod ?? '?'}`,
|
||||||
|
pedidos: real ? Number(real.pedidos) : 0,
|
||||||
|
valorMeta,
|
||||||
|
valorReal,
|
||||||
|
qtdMeta: Number(m.qtdade),
|
||||||
|
qtdReal: real ? Number(real.qtd) : 0,
|
||||||
|
pesoMeta,
|
||||||
|
pesoReal,
|
||||||
|
fatorMeta: Number(m.vl_fator),
|
||||||
|
fatorReal: pesoReal > 0 ? valorReal / pesoReal : 0,
|
||||||
|
pct: valorMeta > 0 ? Math.round((valorReal / valorMeta) * 100) : 0,
|
||||||
|
falta: Math.max(0, valorMeta - valorReal),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.valorMeta - a.valorMeta);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: { atingido, total: targetAmount, pct, falta },
|
meta: { atingido, total: targetAmount, pct, falta },
|
||||||
|
metaDimensao,
|
||||||
|
metasPorGrupo,
|
||||||
comissao: { fixa, flex, total: fixa + flex },
|
comissao: { fixa, flex, total: fixa + flex },
|
||||||
pedidosMes,
|
pedidosMes,
|
||||||
pedidosRecentes: recentRows.map((o) => ({
|
pedidosRecentes: recentRows.map((o) => ({
|
||||||
@@ -178,7 +268,10 @@ export class DashboardService {
|
|||||||
numPedSar: (o.num_ped_sar ?? '').trim(),
|
numPedSar: (o.num_ped_sar ?? '').trim(),
|
||||||
numero: Number(o.numero),
|
numero: Number(o.numero),
|
||||||
idCliente: Number(o.id_cliente),
|
idCliente: Number(o.id_cliente),
|
||||||
|
nomeCliente: o.nome_cliente ?? null,
|
||||||
|
razaoCliente: o.razao_cliente ?? null,
|
||||||
codVendedor: Number(o.cod_vendedor),
|
codVendedor: Number(o.cod_vendedor),
|
||||||
|
nomeVendedor: o.nome_vendedor ?? null,
|
||||||
situa: Number(o.situa),
|
situa: Number(o.situa),
|
||||||
statusDescr: o.status_descr,
|
statusDescr: o.status_descr,
|
||||||
dtPedido: new Date(o.dt_pedido).toISOString(),
|
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)
|
// 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 thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
|
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
|
||||||
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 (
|
FROM (
|
||||||
SELECT c.id_cliente, c.cod_vendedor
|
SELECT c.id_cliente, c.cod_vendedor
|
||||||
FROM vw_clientes c
|
FROM vw_clientes c
|
||||||
LEFT JOIN vw_pedidos_erp p
|
LEFT JOIN vw_pedidos_erp p
|
||||||
ON p.id_cliente = c.id_cliente
|
ON p.id_cliente = c.id_cliente
|
||||||
AND p.id_empresa = c.id_empresa
|
AND p.id_empresa = ${idEmpresa}
|
||||||
AND p.situa != 5
|
AND p.situa != 5
|
||||||
WHERE c.id_empresa = ${idEmpresa}
|
WHERE c.ativo = 1
|
||||||
AND c.ativo = 1
|
|
||||||
GROUP BY c.id_cliente, c.cod_vendedor
|
GROUP BY c.id_cliente, c.cod_vendedor
|
||||||
HAVING MAX(p.dt_pedido) IS NULL
|
HAVING MAX(p.dt_pedido) IS NULL
|
||||||
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
|
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
|
||||||
) inativos
|
) inativos
|
||||||
GROUP BY cod_vendedor
|
GROUP BY inativos.cod_vendedor
|
||||||
ORDER BY COUNT(*) DESC
|
ORDER BY COUNT(*) DESC
|
||||||
LIMIT 3
|
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]) => ({
|
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
|
||||||
id: o.id,
|
id: o.id,
|
||||||
numPedSar: o.numPedSar,
|
numPedSar: o.numPedSar,
|
||||||
idCliente: o.idCliente,
|
idCliente: o.idCliente,
|
||||||
|
nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null,
|
||||||
|
razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null,
|
||||||
codVendedor: o.codVendedor,
|
codVendedor: o.codVendedor,
|
||||||
|
nomeVendedor: repNameMap.get(o.codVendedor) ?? null,
|
||||||
situa: o.situa,
|
situa: o.situa,
|
||||||
dtPedido: o.dtPedido.toISOString(),
|
dtPedido: o.dtPedido.toISOString(),
|
||||||
total: String(o.total),
|
total: String(o.total),
|
||||||
@@ -287,6 +408,7 @@ export class DashboardService {
|
|||||||
},
|
},
|
||||||
inativosPorRep: inativosPorRep.map((r) => ({
|
inativosPorRep: inativosPorRep.map((r) => ({
|
||||||
codVendedor: Number(r.cod_vendedor),
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome_vendedor ?? null,
|
||||||
inativosCount: parseInt(r.inativos_count, 10),
|
inativosCount: parseInt(r.inativos_count, 10),
|
||||||
})),
|
})),
|
||||||
syncedAt: now.toISOString(),
|
syncedAt: now.toISOString(),
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export class OrdersController {
|
|||||||
return this.orders.create(parsed);
|
return this.orders.create(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/transmit')
|
||||||
|
transmit(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||||
|
return this.orders.transmit(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id/approve')
|
@Patch(':id/approve')
|
||||||
approve(
|
approve(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import type {
|
|||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
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
|
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
|
||||||
|
const SITUA_ORCAMENTO = 0;
|
||||||
const SITUA_PENDENTE = 1;
|
const SITUA_PENDENTE = 1;
|
||||||
const SITUA_APROVADO = 2;
|
const SITUA_APROVADO = 2;
|
||||||
const SITUA_CANCELADO = 3;
|
const SITUA_CANCELADO = 3;
|
||||||
@@ -78,6 +79,7 @@ export class OrdersService {
|
|||||||
nome_cliente: string | null;
|
nome_cliente: string | null;
|
||||||
razao_cliente: string | null;
|
razao_cliente: string | null;
|
||||||
cod_vendedor: number;
|
cod_vendedor: number;
|
||||||
|
nome_vendedor: string | null;
|
||||||
situa: number;
|
situa: number;
|
||||||
status_descr: string;
|
status_descr: string;
|
||||||
dt_pedido: Date;
|
dt_pedido: Date;
|
||||||
@@ -86,25 +88,94 @@ export class OrdersService {
|
|||||||
obs: string | null;
|
obs: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rows, countRows] = await Promise.all([
|
// Pedidos SAR-nativos (Orçamento/Transmitido) — ainda não estão no ERP.
|
||||||
prisma.$queryRawUnsafe<ErpRow[]>(`
|
const sarWhere: Prisma.PedidoWhereInput = {
|
||||||
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
|
idEmpresa,
|
||||||
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
|
...(role === 'rep' ? { codVendedor } : {}),
|
||||||
c.nome AS nome_cliente, c.razao AS razao_cliente
|
...(idCliente != null ? { idCliente } : {}),
|
||||||
FROM vw_pedidos_erp e
|
...(situa != null ? { situa } : {}),
|
||||||
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente AND c.id_empresa = e.id_empresa
|
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
|
||||||
${filters}
|
...(from || to
|
||||||
ORDER BY e.dt_pedido DESC
|
? {
|
||||||
LIMIT ${limit} OFFSET ${offset}
|
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 }]>(`
|
prisma.$queryRawUnsafe<[{ count: string }]>(`
|
||||||
SELECT COUNT(*)::text AS count FROM vw_pedidos_erp e ${filters}
|
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<ErpRow[]>(`
|
||||||
|
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
|
||||||
|
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
|
||||||
|
c.nome AS nome_cliente, c.razao AS razao_cliente,
|
||||||
|
(SELECT r.nome FROM vw_representantes r
|
||||||
|
WHERE r.codigo = e.cod_vendedor
|
||||||
|
LIMIT 1) AS nome_vendedor
|
||||||
|
FROM vw_pedidos_erp e
|
||||||
|
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
|
||||||
|
${filters}
|
||||||
|
ORDER BY e.dt_pedido DESC
|
||||||
|
LIMIT ${erpNeeded} OFFSET ${erpOffset}
|
||||||
|
`)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Resolve nomes (cliente/rep) dos pedidos SAR em lote — views globais.
|
||||||
|
const cliIds = [...new Set(sarSlice.map((p) => p.idCliente))];
|
||||||
|
const repCods = [...new Set(sarSlice.map((p) => p.codVendedor))];
|
||||||
|
const [cliNameRows, repNameRows] = await Promise.all([
|
||||||
|
cliIds.length
|
||||||
|
? prisma.$queryRawUnsafe<
|
||||||
|
{ id_cliente: number; nome: string | null; razao: string | null }[]
|
||||||
|
>(
|
||||||
|
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
|
||||||
|
)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
repCods.length
|
||||||
|
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||||
|
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
|
||||||
|
)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
const cliMap = new Map(cliNameRows.map((c) => [Number(c.id_cliente), c]));
|
||||||
|
const repMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
|
||||||
|
|
||||||
|
const sarData: PedidoSummary[] = sarSlice.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
numPedSar: p.numPedSar,
|
||||||
|
idCliente: p.idCliente,
|
||||||
|
nomeCliente: cliMap.get(p.idCliente)?.nome ?? null,
|
||||||
|
razaoCliente: cliMap.get(p.idCliente)?.razao ?? null,
|
||||||
|
codVendedor: p.codVendedor,
|
||||||
|
nomeVendedor: repMap.get(p.codVendedor) ?? null,
|
||||||
|
situa: p.situa,
|
||||||
|
dtPedido: p.dtPedido.toISOString(),
|
||||||
|
total: decimalToString(p.total),
|
||||||
|
descontoPerc: decimalToString(p.descontoPerc),
|
||||||
|
obs: p.obs,
|
||||||
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
fonte: 'sar' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const erpData: PedidoSummary[] = erpRows.map((o) => ({
|
||||||
id: `erp-${o.id_pedido}`,
|
id: `erp-${o.id_pedido}`,
|
||||||
numPedSar: (o.num_ped_sar ?? '').trim(),
|
numPedSar: (o.num_ped_sar ?? '').trim(),
|
||||||
numero: Number(o.numero),
|
numero: Number(o.numero),
|
||||||
@@ -112,6 +183,7 @@ export class OrdersService {
|
|||||||
nomeCliente: o.nome_cliente ?? null,
|
nomeCliente: o.nome_cliente ?? null,
|
||||||
razaoCliente: o.razao_cliente ?? null,
|
razaoCliente: o.razao_cliente ?? null,
|
||||||
codVendedor: Number(o.cod_vendedor),
|
codVendedor: Number(o.cod_vendedor),
|
||||||
|
nomeVendedor: o.nome_vendedor ?? null,
|
||||||
// Normaliza situa SIG → SAR para consistência com pedidos SAR
|
// Normaliza situa SIG → SAR para consistência com pedidos SAR
|
||||||
situa: sigToSar(Number(o.situa)),
|
situa: sigToSar(Number(o.situa)),
|
||||||
statusDescr: o.status_descr,
|
statusDescr: o.status_descr,
|
||||||
@@ -120,10 +192,10 @@ export class OrdersService {
|
|||||||
descontoPerc: o.desconto_perc ?? '0',
|
descontoPerc: o.desconto_perc ?? '0',
|
||||||
obs: o.obs ?? null,
|
obs: o.obs ?? null,
|
||||||
createdAt: new Date(o.dt_pedido).toISOString(),
|
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<PedidoDetail> {
|
async findOne(id: string): Promise<PedidoDetail> {
|
||||||
@@ -149,7 +221,8 @@ export class OrdersService {
|
|||||||
return this.mapDetail(o);
|
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).
|
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
|
||||||
async create(dto: CreatePedido): Promise<PedidoDetail> {
|
async create(dto: CreatePedido): Promise<PedidoDetail> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
@@ -170,15 +243,6 @@ export class OrdersService {
|
|||||||
if (existing) return this.mapDetail(existing);
|
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 itemsData = dto.itens.map((it) => {
|
||||||
const descontoValor =
|
const descontoValor =
|
||||||
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
|
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 descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
|
||||||
const total = Math.round(totalProdutos * (1 - 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({
|
const lastOrder = await prisma.pedido.findFirst({
|
||||||
where: { idEmpresa },
|
orderBy: { numPedSar: 'desc' },
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
select: { numPedSar: true },
|
select: { numPedSar: true },
|
||||||
});
|
});
|
||||||
const seq = lastOrder ? parseInt(lastOrder.numPedSar.replace('SAR-', ''), 10) + 1 : 1;
|
const seq = lastOrder ? parseInt(lastOrder.numPedSar.replace('SAR-', ''), 10) + 1 : 1;
|
||||||
@@ -246,15 +309,61 @@ export class OrdersService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (situa === SITUA_PENDENTE) {
|
return this.mapDetail(pedido);
|
||||||
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<PedidoDetail> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId') ?? '0';
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
|
// Rep só transmite o próprio orçamento
|
||||||
|
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||||
|
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
||||||
|
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||||
|
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Pedido não é um orçamento (situa: ${pedido.situa}) — só orçamentos podem ser transmitidos`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alçada do rep (codGrupo=0 = default; fallback 5%) — bloqueia se desconto acima.
|
||||||
|
const limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
|
||||||
|
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
||||||
|
const limiteMax = limitMap.get(0) ?? 5;
|
||||||
|
const desconto = Number(pedido.descontoPerc);
|
||||||
|
if (desconto > limiteMax) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Desconto de ${desconto}% acima do máximo permitido para você (${limiteMax}%). Reduza o desconto para transmitir o pedido.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
|
||||||
|
await prisma.historicoPedido.create({
|
||||||
|
data: {
|
||||||
|
idPedido: id,
|
||||||
|
situaAnterior: SITUA_ORCAMENTO,
|
||||||
|
situaNova: SITUA_APROVADO,
|
||||||
|
changedBy: codVendedor,
|
||||||
|
changedAt: now,
|
||||||
|
nota: 'Transmitido',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const final = await prisma.pedido.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapDetail(final);
|
||||||
}
|
}
|
||||||
|
|
||||||
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
||||||
@@ -364,7 +473,37 @@ export class OrdersService {
|
|||||||
return this.mapDetail(final);
|
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;
|
id: string;
|
||||||
numPedSar: string;
|
numPedSar: string;
|
||||||
idCliente: number;
|
idCliente: number;
|
||||||
@@ -406,12 +545,16 @@ export class OrdersService {
|
|||||||
nota: string | null;
|
nota: string | null;
|
||||||
changedAt: Date;
|
changedAt: Date;
|
||||||
}[];
|
}[];
|
||||||
}): PedidoDetail {
|
}): Promise<PedidoDetail> {
|
||||||
|
const names = await this.lookupNames(o.idCliente, o.codVendedor);
|
||||||
return {
|
return {
|
||||||
id: o.id,
|
id: o.id,
|
||||||
numPedSar: o.numPedSar,
|
numPedSar: o.numPedSar,
|
||||||
idCliente: o.idCliente,
|
idCliente: o.idCliente,
|
||||||
|
nomeCliente: names.nomeCliente,
|
||||||
|
razaoCliente: names.razaoCliente,
|
||||||
codVendedor: o.codVendedor,
|
codVendedor: o.codVendedor,
|
||||||
|
nomeVendedor: names.nomeVendedor,
|
||||||
situa: o.situa,
|
situa: o.situa,
|
||||||
dtPedido: o.dtPedido.toISOString(),
|
dtPedido: o.dtPedido.toISOString(),
|
||||||
total: decimalToString(o.total),
|
total: decimalToString(o.total),
|
||||||
|
|||||||
@@ -1,5 +1,106 @@
|
|||||||
// Service Worker SAR — C6 Web Push
|
// Service Worker SAR
|
||||||
// Recebe push events e exibe notificação nativa. Clique abre a URL do payload.
|
// 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) => {
|
self.addEventListener('push', (event) => {
|
||||||
const data = event.data?.json() ?? {};
|
const data = event.data?.json() ?? {};
|
||||||
|
|||||||
@@ -576,8 +576,15 @@ function CustomerDetailsDrawer({
|
|||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<span style={label}>Cód. Vendedor</span>
|
<span style={label}>Representante</span>
|
||||||
<Text style={{ fontSize: 13 }}>{summary.codVendedor}</Text>
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
{summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`}
|
||||||
|
{summary.nomeVendedor && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>
|
||||||
|
(cód. {summary.codVendedor})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
</Col>
|
</Col>
|
||||||
{summary.dtUltimaCompra && (
|
{summary.dtUltimaCompra && (
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
@@ -787,8 +794,8 @@ function CustomerAnalysisDrawer({
|
|||||||
color: urgencyColor,
|
color: urgencyColor,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Cód. Vendedor',
|
label: 'Representante',
|
||||||
value: String(summary.codVendedor),
|
value: summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`,
|
||||||
icon: <UserOutlined />,
|
icon: <UserOutlined />,
|
||||||
color: '#64748B',
|
color: '#64748B',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
App,
|
||||||
AutoComplete,
|
AutoComplete,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -27,24 +28,20 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate, useSearch } from '@tanstack/react-router';
|
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 { 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 { apiFetch } from '../../lib/api-client';
|
||||||
|
import { enqueueOrder } from '../../lib/offline/order-queue';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
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 ────────────────────────────────────────────────────────────
|
// ─── Tipos internos ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type CartItem = {
|
type CartItem = {
|
||||||
@@ -424,6 +421,7 @@ export function NewOrderPage() {
|
|||||||
const { clientId: clientIdParam } = useSearch({ strict: false }) as SearchParams;
|
const { clientId: clientIdParam } = useSearch({ strict: false }) as SearchParams;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
// ── Dados do cliente ──
|
// ── Dados do cliente ──
|
||||||
const [clientSearch, setClientSearch] = useState('');
|
const [clientSearch, setClientSearch] = useState('');
|
||||||
@@ -437,6 +435,7 @@ export function NewOrderPage() {
|
|||||||
|
|
||||||
// ── Campos comerciais ──
|
// ── Campos comerciais ──
|
||||||
const { data: pautas = [] } = usePautas();
|
const { data: pautas = [] } = usePautas();
|
||||||
|
const { data: formasPagamento = [] } = useFormasPagamento();
|
||||||
const [idPauta, setIdPauta] = useState<number | undefined>();
|
const [idPauta, setIdPauta] = useState<number | undefined>();
|
||||||
const [codFormapag, setCodFormapag] = useState<number | undefined>();
|
const [codFormapag, setCodFormapag] = useState<number | undefined>();
|
||||||
const [contato, setContato] = useState('');
|
const [contato, setContato] = useState('');
|
||||||
@@ -491,7 +490,6 @@ export function NewOrderPage() {
|
|||||||
if (!effectiveClient) throw new Error('Selecione um cliente para continuar.');
|
if (!effectiveClient) throw new Error('Selecione um cliente para continuar.');
|
||||||
if (cart.length === 0) throw new Error('Adicione ao menos um produto ao pedido.');
|
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 = [
|
const obsCompleta = [
|
||||||
contato ? `Contato: ${contato}` : null,
|
contato ? `Contato: ${contato}` : null,
|
||||||
numOC ? `OC: ${numOC}` : null,
|
numOC ? `OC: ${numOC}` : null,
|
||||||
@@ -517,12 +515,35 @@ export function NewOrderPage() {
|
|||||||
descontoPerc: it.descontoPerc,
|
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: ['orders'] });
|
||||||
void qc.invalidateQueries({ queryKey: ['clients'] });
|
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'),
|
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'),
|
||||||
});
|
});
|
||||||
@@ -633,7 +654,10 @@ export function NewOrderPage() {
|
|||||||
allowClear
|
allowClear
|
||||||
value={codFormapag}
|
value={codFormapag}
|
||||||
onChange={setCodFormapag}
|
onChange={setCodFormapag}
|
||||||
options={COND_PAGAMENTO}
|
options={formasPagamento.map((f: FormaPagamento) => ({
|
||||||
|
value: f.codigo,
|
||||||
|
label: f.descricao,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={8}>
|
<Col xs={24} sm={8}>
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FilePdfOutlined } from '@ant-design/icons';
|
||||||
import type { TableColumnsType } from 'antd';
|
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 type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
|
||||||
import { SITUA_LABEL } from '@sar/api-interface';
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
import { useOrderDetail } from '../../lib/queries/orders';
|
import { useOrderDetail } from '../../lib/queries/orders';
|
||||||
@@ -35,6 +36,7 @@ const { TextArea } = Input;
|
|||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SITUA_COLOR: Record<number, string> = {
|
const SITUA_COLOR: Record<number, string> = {
|
||||||
|
0: 'default',
|
||||||
1: 'warning',
|
1: 'warning',
|
||||||
2: 'processing',
|
2: 'processing',
|
||||||
3: 'error',
|
3: 'error',
|
||||||
@@ -244,12 +246,14 @@ function RejectModal({
|
|||||||
|
|
||||||
export function OrderDetailPage() {
|
export function OrderDetailPage() {
|
||||||
const { id } = useParams({ from: '/pedidos/$id' });
|
const { id } = useParams({ from: '/pedidos/$id' });
|
||||||
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { data: order, isLoading, error } = useOrderDetail(id);
|
const { data: order, isLoading, error } = useOrderDetail(id);
|
||||||
const { data: clientOrders } = useClientOrders(order?.idCliente);
|
const { data: clientOrders } = useClientOrders(order?.idCliente);
|
||||||
|
|
||||||
const role = getRoleFromToken();
|
const role = getRoleFromToken();
|
||||||
const canAct = role !== 'rep' && order?.situa === 1;
|
const canAct = role !== 'rep' && order?.situa === 1;
|
||||||
|
const canTransmit = role === 'rep' && order?.situa === 0;
|
||||||
const canShare =
|
const canShare =
|
||||||
role === 'rep' &&
|
role === 'rep' &&
|
||||||
(order?.situa === 2 || order?.situa === 4) &&
|
(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 <Spin style={{ display: 'block', marginTop: 64 }} />;
|
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||||
if (error || !order)
|
if (error || !order)
|
||||||
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
||||||
@@ -297,8 +311,11 @@ export function OrderDetailPage() {
|
|||||||
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
|
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Orçamento: tela mais larga para consulta/revisão com o cliente.
|
||||||
|
const isOrcamento = order.situa === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24, maxWidth: 960 }}>
|
<div style={{ padding: 24, maxWidth: isOrcamento ? 1320 : 960, margin: '0 auto' }}>
|
||||||
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
||||||
<Link to="/pedidos">← Pedidos</Link>
|
<Link to="/pedidos">← Pedidos</Link>
|
||||||
<Title level={3} style={{ margin: 0 }}>
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
@@ -322,6 +339,25 @@ export function OrderDetailPage() {
|
|||||||
{timeWaiting !== null && timeWaiting > 2 && (
|
{timeWaiting !== null && timeWaiting > 2 && (
|
||||||
<Tag color="red">Urgente — {timeWaiting}h aguardando</Tag>
|
<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 && (
|
{canAct && (
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" onClick={() => setApproveOpen(true)}>
|
<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">
|
<Descriptions.Item label="Cliente">
|
||||||
<Link to="/clientes/$id" params={{ id: String(order.idCliente) }}>
|
<Link to="/clientes/$id" params={{ id: String(order.idCliente) }}>
|
||||||
Cód. {order.idCliente}
|
{order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`}
|
||||||
</Link>
|
</Link>
|
||||||
</Descriptions.Item>
|
</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">
|
<Descriptions.Item label="Data">
|
||||||
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
|
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
@@ -399,7 +442,7 @@ export function OrderDetailPage() {
|
|||||||
columns={itemColumns}
|
columns={itemColumns}
|
||||||
dataSource={order.itens}
|
dataSource={order.itens}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size={isOrcamento ? 'middle' : 'small'}
|
||||||
style={{ marginBottom: 24 }}
|
style={{ marginBottom: 24 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
430
apps/web/src/cockpits/rep/OrderPrintPage.tsx
Normal file
430
apps/web/src/cockpits/rep/OrderPrintPage.tsx
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Button, Spin, Alert } from 'antd';
|
||||||
|
import { PrinterOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { useParams, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
|
import { useOrderDetail } from '../../lib/queries/orders';
|
||||||
|
import { useClientDetail } from '../../lib/queries/clients';
|
||||||
|
import { useCompany } from '../../lib/queries/company';
|
||||||
|
|
||||||
|
// ─── Paleta / tokens ────────────────────────────────────────────────────────
|
||||||
|
const BLUE = '#003B8E';
|
||||||
|
const INK = '#1F2937';
|
||||||
|
const MUTED = '#64748B';
|
||||||
|
const LINE = '#E5EAF0';
|
||||||
|
|
||||||
|
// ─── Helpers de formatação ──────────────────────────────────────────────────
|
||||||
|
function money(v: string | number | null | undefined): string {
|
||||||
|
const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0);
|
||||||
|
return (isNaN(n as number) ? 0 : (n as number)).toLocaleString('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function qty(v: string | number): string {
|
||||||
|
return Number(v).toLocaleString('pt-BR', { maximumFractionDigits: 3 });
|
||||||
|
}
|
||||||
|
function dateBR(v: string | null | undefined): string {
|
||||||
|
return v ? new Date(v).toLocaleDateString('pt-BR') : '—';
|
||||||
|
}
|
||||||
|
function doc(raw: string | null | undefined): string {
|
||||||
|
if (!raw) return '—';
|
||||||
|
const d = raw.replace(/\D/g, '');
|
||||||
|
if (d.length === 14) return d.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, '$1.$2.$3/$4-$5');
|
||||||
|
if (d.length === 11) return d.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
function phone(raw: string | null | undefined, ddd?: string | null): string {
|
||||||
|
const d = `${ddd ?? ''}${raw ?? ''}`.replace(/\D/g, '');
|
||||||
|
if (d.length === 11) return d.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
|
||||||
|
if (d.length === 10) return d.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3');
|
||||||
|
return raw ?? '—';
|
||||||
|
}
|
||||||
|
function cep(raw: string | null | undefined): string {
|
||||||
|
const d = (raw ?? '').replace(/\D/g, '');
|
||||||
|
return d.length === 8 ? d.replace(/(\d{5})(\d{3})/, '$1-$2') : (raw ?? '');
|
||||||
|
}
|
||||||
|
// Campos char do ERP vêm com padding — limpa, devolve null se vazio.
|
||||||
|
function tx(s: string | null | undefined): string | null {
|
||||||
|
const t = (s ?? '').trim();
|
||||||
|
return t === '' ? null : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Blocos visuais ─────────────────────────────────────────────────────────
|
||||||
|
const label: React.CSSProperties = {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: MUTED,
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Field({ k, v }: { k: string; v: React.ReactNode }) {
|
||||||
|
if (v == null || v === '' || v === '—') return null;
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 5 }}>
|
||||||
|
<span style={label}>{k}</span>
|
||||||
|
<span style={{ fontSize: 11.5, color: INK }}>{v}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderPrintPage() {
|
||||||
|
const { id } = useParams({ from: '/pedidos/$id/imprimir' });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: order, isLoading, error } = useOrderDetail(id);
|
||||||
|
const { data: client } = useClientDetail(order?.idCliente);
|
||||||
|
const { data: empresa } = useCompany();
|
||||||
|
|
||||||
|
// Auto-abre o diálogo de impressão quando tudo carregou.
|
||||||
|
useEffect(() => {
|
||||||
|
if (order && empresa) {
|
||||||
|
const t = setTimeout(() => window.print(), 600);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [order, empresa]);
|
||||||
|
|
||||||
|
if (isLoading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
||||||
|
if (error || !order)
|
||||||
|
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
||||||
|
|
||||||
|
const enderecoCli = client
|
||||||
|
? [
|
||||||
|
tx(client.endereco),
|
||||||
|
tx(client.numEndereco),
|
||||||
|
tx(client.bairro),
|
||||||
|
client.cep ? `CEP ${cep(client.cep)}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
: null;
|
||||||
|
const cgDigits = (client?.cgcpf ?? '').replace(/\D/g, '').length;
|
||||||
|
const docLabel = cgDigits === 11 ? 'CPF' : 'CNPJ';
|
||||||
|
const enderecoEmp = empresa
|
||||||
|
? [empresa.endereco, empresa.numero, empresa.complemento, empresa.bairro]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
: null;
|
||||||
|
const cidadeEmp = empresa
|
||||||
|
? [empresa.cidade, empresa.uf].filter(Boolean).join(' - ') +
|
||||||
|
(empresa.cep ? ` · CEP ${cep(empresa.cep)}` : '')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const clienteNome =
|
||||||
|
tx(order.razaoCliente) ?? tx(order.nomeCliente) ?? `Cliente ${order.idCliente}`;
|
||||||
|
const temDesc = Number(order.descontoValor) > 0 || Number(order.descontoPerc) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#EEF2F7', minHeight: '100vh', padding: '24px 0 60px' }}>
|
||||||
|
{/* Barra de ações (não imprime) */}
|
||||||
|
<div
|
||||||
|
className="no-print"
|
||||||
|
style={{
|
||||||
|
maxWidth: 820,
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate({ to: '/pedidos/$id', params: { id } })}
|
||||||
|
>
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<PrinterOutlined />} onClick={() => window.print()}>
|
||||||
|
Imprimir / Salvar PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documento A4 */}
|
||||||
|
<div
|
||||||
|
className="sar-print"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 820,
|
||||||
|
margin: '0 auto',
|
||||||
|
background: '#fff',
|
||||||
|
boxShadow: '0 4px 24px rgba(0,0,0,0.10)',
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontFamily: "'Plus Jakarta Sans Variable', system-ui, sans-serif",
|
||||||
|
color: INK,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: '22px 28px',
|
||||||
|
borderTop: `5px solid ${BLUE}`,
|
||||||
|
background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)',
|
||||||
|
borderBottom: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: 460 }}>
|
||||||
|
<div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}>
|
||||||
|
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div>
|
||||||
|
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}>
|
||||||
|
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
|
||||||
|
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
|
||||||
|
{enderecoEmp && <div>{enderecoEmp}</div>}
|
||||||
|
{cidadeEmp && <div>{cidadeEmp}</div>}
|
||||||
|
{(empresa?.telefone || empresa?.email) && (
|
||||||
|
<div>
|
||||||
|
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
|
||||||
|
{empresa?.telefone && empresa?.email && <> · </>}
|
||||||
|
{empresa?.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: 10, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
|
||||||
|
PEDIDO
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
|
||||||
|
{order.numPedSar}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
marginTop: 6,
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: 20,
|
||||||
|
background: `${BLUE}12`,
|
||||||
|
color: BLUE,
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SITUA_LABEL[order.situa] ?? String(order.situa)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}>
|
||||||
|
Emissão: {dateBR(order.dtPedido)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Cliente + Representante ─────────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', gap: 0 }}>
|
||||||
|
<div style={{ flex: 1.4, padding: '16px 28px', borderRight: `1px solid ${LINE}` }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: BLUE,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
CLIENTE
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 2 }}>
|
||||||
|
{clienteNome}
|
||||||
|
</div>
|
||||||
|
{tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && (
|
||||||
|
<div style={{ fontSize: 11, color: MUTED, marginBottom: 8 }}>{tx(client?.nome)}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Field k={docLabel} v={doc(client?.cgcpf)} />
|
||||||
|
<Field k="Inscr. Estadual" v={tx(client?.inscricaoEstadual)} />
|
||||||
|
<Field k="Endereço" v={enderecoCli} />
|
||||||
|
<Field k="Telefone" v={phone(client?.telefone, client?.ddd)} />
|
||||||
|
<Field k="E-mail" v={tx(client?.email)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, padding: '16px 28px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: BLUE,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
REPRESENTANTE
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 8 }}>
|
||||||
|
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
|
||||||
|
</div>
|
||||||
|
<Field k="Código" v={String(order.codVendedor)} />
|
||||||
|
<Field k="Data do pedido" v={dateBR(order.dtPedido)} />
|
||||||
|
<Field k="Nº do pedido" v={order.numPedSar} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Itens ───────────────────────────────────────────────────────── */}
|
||||||
|
<div style={{ padding: '8px 28px 0' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#F4F7FB' }}>
|
||||||
|
{['Cód.', 'Produto', 'Qtd', 'Preço un.', 'Desc.', 'Total'].map((h, i) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
style={{
|
||||||
|
textAlign: i >= 2 ? 'right' : 'left',
|
||||||
|
padding: '8px 8px',
|
||||||
|
color: MUTED,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 9.5,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
borderBottom: `2px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{order.itens.map((it, idx) => (
|
||||||
|
<tr key={it.id} style={{ background: idx % 2 ? '#FBFCFE' : '#fff' }}>
|
||||||
|
<td
|
||||||
|
style={{ padding: '7px 8px', color: MUTED, borderBottom: `1px solid ${LINE}` }}
|
||||||
|
>
|
||||||
|
{it.codProduto ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', color: INK, borderBottom: `1px solid ${LINE}` }}>
|
||||||
|
{it.descProduto ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '7px 8px',
|
||||||
|
textAlign: 'right',
|
||||||
|
borderBottom: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{qty(it.qtd)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '7px 8px',
|
||||||
|
textAlign: 'right',
|
||||||
|
borderBottom: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{money(it.precoUnitario)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '7px 8px',
|
||||||
|
textAlign: 'right',
|
||||||
|
color: MUTED,
|
||||||
|
borderBottom: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Number(it.descontoPerc) > 0 ? `${Number(it.descontoPerc)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '7px 8px',
|
||||||
|
textAlign: 'right',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: INK,
|
||||||
|
borderBottom: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{money(it.total)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Totais ──────────────────────────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '14px 28px 4px' }}>
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<TotRow k="Total dos produtos" v={money(order.totalProdutos)} />
|
||||||
|
{Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />}
|
||||||
|
{Number(order.totalIcmsst) > 0 && <TotRow k="ICMS-ST" v={money(order.totalIcmsst)} />}
|
||||||
|
{temDesc && (
|
||||||
|
<TotRow
|
||||||
|
k={`Desconto${Number(order.descontoPerc) > 0 ? ` (${Number(order.descontoPerc)}%)` : ''}`}
|
||||||
|
v={`- ${money(order.descontoValor)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: BLUE,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.04em' }}>TOTAL</span>
|
||||||
|
<span style={{ fontSize: 17, fontWeight: 800 }}>{money(order.total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Observações + rodapé ────────────────────────────────────────── */}
|
||||||
|
{order.obs && (
|
||||||
|
<div style={{ padding: '10px 28px 0' }}>
|
||||||
|
<span style={label}>Observações</span>
|
||||||
|
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: '18px 28px 0',
|
||||||
|
padding: '12px 0 18px',
|
||||||
|
borderTop: `1px solid ${LINE}`,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: 9.5,
|
||||||
|
color: '#94A3B8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Documento sem valor fiscal · Pedido de venda emitido pelo representante via SAR.
|
||||||
|
</span>
|
||||||
|
<span>SAR · Powered by JCS Sistemas</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CSS de impressão: esconde tudo menos o documento */}
|
||||||
|
<style>{`
|
||||||
|
@media print {
|
||||||
|
@page { size: A4; margin: 10mm; }
|
||||||
|
body * { visibility: hidden !important; }
|
||||||
|
.sar-print, .sar-print * { visibility: visible !important; }
|
||||||
|
.sar-print { position: absolute; left: 0; top: 0; width: 100% !important;
|
||||||
|
max-width: none !important; box-shadow: none !important; border-radius: 0 !important; }
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotRow({ k, v }: { k: string; v: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '4px 14px',
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: MUTED,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{k}</span>
|
||||||
|
<span style={{ color: INK, fontWeight: 600 }}>{v}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
|
DatePicker,
|
||||||
Drawer,
|
Drawer,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Grid,
|
Grid,
|
||||||
@@ -19,6 +20,9 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
@@ -37,6 +41,8 @@ import { Link, useNavigate } from '@tanstack/react-router';
|
|||||||
import type { PedidoSummary } from '@sar/api-interface';
|
import type { PedidoSummary } from '@sar/api-interface';
|
||||||
import { SITUA_LABEL } from '@sar/api-interface';
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
import { useOrderList, useOrderDetail } from '../../lib/queries/orders';
|
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 { Title, Text } = Typography;
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
@@ -74,8 +80,9 @@ function periodRange(p: string): { from?: string; to?: string } {
|
|||||||
// ─── Status Config ────────────────────────────────────────────────────────────
|
// ─── Status Config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STATUS: Record<number, { label: string; color: string; rowBg: string; tagColor: string }> = {
|
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' },
|
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' },
|
3: { label: 'Cancelado', color: '#cf1322', rowBg: '#fff1f0', tagColor: 'red' },
|
||||||
4: { label: 'Faturado', color: '#1d39c4', rowBg: '#f0f5ff', tagColor: 'geekblue' },
|
4: { label: 'Faturado', color: '#1d39c4', rowBg: '#f0f5ff', tagColor: 'geekblue' },
|
||||||
};
|
};
|
||||||
@@ -220,7 +227,8 @@ function OrderActionsMenu({
|
|||||||
key: 'pdf',
|
key: 'pdf',
|
||||||
icon: <FilePdfOutlined />,
|
icon: <FilePdfOutlined />,
|
||||||
label: 'Gerar PDF',
|
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',
|
key: 'cancel',
|
||||||
@@ -242,6 +250,7 @@ function OrderActionsMenu({
|
|||||||
// ─── OrderDetailDrawer ────────────────────────────────────────────────────────
|
// ─── OrderDetailDrawer ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => void }) {
|
function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data, isLoading } = useOrderDetail(id ?? undefined);
|
const { data, isLoading } = useOrderDetail(id ?? undefined);
|
||||||
|
|
||||||
const timelineItems = (data?.historico ?? []).map((h) => ({
|
const timelineItems = (data?.historico ?? []).map((h) => ({
|
||||||
@@ -292,7 +301,13 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
<Space>
|
<Space>
|
||||||
<Button onClick={onClose}>Fechar</Button>
|
<Button onClick={onClose}>Fechar</Button>
|
||||||
{data && (
|
{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
|
Gerar PDF
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -333,6 +348,10 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<span style={label}>Representante</span>
|
||||||
|
<Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text>
|
||||||
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<span style={label}>Total</span>
|
<span style={label}>Total</span>
|
||||||
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
|
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
|
||||||
@@ -461,7 +480,11 @@ function MobileOrderCard({
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<EyeOutlined />}
|
icon={<EyeOutlined />}
|
||||||
disabled={order.fonte === 'erp'}
|
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 }}
|
style={{ borderRadius: 6 }}
|
||||||
>
|
>
|
||||||
Ver
|
Ver
|
||||||
@@ -488,6 +511,7 @@ export function OrdersPage() {
|
|||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const { message: msg } = App.useApp();
|
const { message: msg } = App.useApp();
|
||||||
|
const { orders: pendingOrders, refresh: refreshPending } = usePendingOrders();
|
||||||
|
|
||||||
const stats = useOrderStats();
|
const stats = useOrderStats();
|
||||||
|
|
||||||
@@ -495,11 +519,15 @@ export function OrdersPage() {
|
|||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [situaFilter, setSituaFilter] = useState<number | undefined>();
|
const [situaFilter, setSituaFilter] = useState<number | undefined>();
|
||||||
const [period, setPeriod] = useState('');
|
const [period, setPeriod] = useState('');
|
||||||
|
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [drawerOrderId, setDrawerOrderId] = useState<string | null>(null);
|
const [drawerOrderId, setDrawerOrderId] = useState<string | null>(null);
|
||||||
const limit = 20;
|
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({
|
const { data, isLoading, isFetching } = useOrderList({
|
||||||
numPedSar: query || undefined,
|
numPedSar: query || undefined,
|
||||||
@@ -513,7 +541,7 @@ export function OrdersPage() {
|
|||||||
const rows = data?.data ?? [];
|
const rows = data?.data ?? [];
|
||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
|
|
||||||
const hasFilters = !!query || !!situaFilter || !!period;
|
const hasFilters = !!query || !!situaFilter || !!period || !!range;
|
||||||
|
|
||||||
function commitSearch() {
|
function commitSearch() {
|
||||||
setQuery(search.trim());
|
setQuery(search.trim());
|
||||||
@@ -525,6 +553,7 @@ export function OrdersPage() {
|
|||||||
setQuery('');
|
setQuery('');
|
||||||
setSituaFilter(undefined);
|
setSituaFilter(undefined);
|
||||||
setPeriod('');
|
setPeriod('');
|
||||||
|
setRange(null);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +652,11 @@ export function OrdersPage() {
|
|||||||
style={{ borderRadius: 6 }}
|
style={{ borderRadius: 6 }}
|
||||||
title="Ver detalhes"
|
title="Ver detalhes"
|
||||||
disabled={row.fonte === 'erp'}
|
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)} />
|
<OrderActionsMenu order={row} onView={(id) => setDrawerOrderId(id)} />
|
||||||
</Space>
|
</Space>
|
||||||
@@ -683,7 +716,7 @@ export function OrdersPage() {
|
|||||||
>
|
>
|
||||||
<Row gutter={[12, 12]} align="middle">
|
<Row gutter={[12, 12]} align="middle">
|
||||||
{/* Busca */}
|
{/* Busca */}
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={6}>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<SearchOutlined
|
<SearchOutlined
|
||||||
style={{
|
style={{
|
||||||
@@ -719,7 +752,7 @@ export function OrdersPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<Col xs={12} sm={8} md={5}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
placeholder="Status"
|
placeholder="Status"
|
||||||
@@ -730,16 +763,17 @@ export function OrdersPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
options={[
|
options={[
|
||||||
|
{ value: 0, label: 'Orçamento' },
|
||||||
{ value: 1, label: 'Ag. Aprovação' },
|
{ value: 1, label: 'Ag. Aprovação' },
|
||||||
{ value: 2, label: 'Aprovado' },
|
{ value: 2, label: 'Transmitido' },
|
||||||
{ value: 3, label: 'Cancelado' },
|
{ value: 3, label: 'Cancelado' },
|
||||||
{ value: 4, label: 'Faturado' },
|
{ value: 4, label: 'Faturado' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{/* Período */}
|
{/* Período (atalho) */}
|
||||||
<Col xs={12} sm={8} md={5}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
placeholder="Período"
|
placeholder="Período"
|
||||||
@@ -747,6 +781,7 @@ export function OrdersPage() {
|
|||||||
value={period || undefined}
|
value={period || undefined}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setPeriod(v ?? '');
|
setPeriod(v ?? '');
|
||||||
|
if (v) setRange(null); // atalho limpa o intervalo customizado
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
options={[
|
options={[
|
||||||
@@ -757,8 +792,28 @@ export function OrdersPage() {
|
|||||||
/>
|
/>
|
||||||
</Col>
|
</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 */}
|
{/* Limpar */}
|
||||||
<Col xs={12} sm={8} md={3}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Button
|
<Button
|
||||||
style={{ width: '100%', borderRadius: 6 }}
|
style={{ width: '100%', borderRadius: 6 }}
|
||||||
icon={<ClearOutlined />}
|
icon={<ClearOutlined />}
|
||||||
@@ -778,6 +833,68 @@ export function OrdersPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</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 ────────────────────────────────────────────── */}
|
{/* ── Lista / tabela ────────────────────────────────────────────── */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: 64 }}>
|
<div style={{ textAlign: 'center', padding: 64 }}>
|
||||||
@@ -845,7 +962,9 @@ export function OrdersPage() {
|
|||||||
scroll={{ x: 900 }}
|
scroll={{ x: 900 }}
|
||||||
onRow={(row) => ({
|
onRow={(row) => ({
|
||||||
onClick: () => {
|
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: {
|
style: {
|
||||||
background: STATUS[row.situa]?.rowBg ?? '#fff',
|
background: STATUS[row.situa]?.rowBg ?? '#fff',
|
||||||
|
|||||||
@@ -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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faArrowTrendUp,
|
faArrowTrendUp,
|
||||||
|
faBullseye,
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faClipboardList,
|
faClipboardList,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { Link } from '@tanstack/react-router';
|
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 { SITUA_LABEL } from '@sar/api-interface';
|
||||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||||
import { useCurrentUser } from '../../lib/queries/auth';
|
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() {
|
export function RepPainel() {
|
||||||
const { data, isLoading } = useRepDashboard();
|
const { data, isLoading } = useRepDashboard();
|
||||||
const { data: user } = useCurrentUser();
|
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 (
|
return (
|
||||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
@@ -162,6 +256,82 @@ export function RepPainel() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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 */}
|
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
@@ -245,7 +415,7 @@ export function RepPainel() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
Cód. cliente {o.idCliente}
|
{o.razaoCliente ?? o.nomeCliente ?? `Cód. cliente ${o.idCliente}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Flex gap={8} align="center">
|
<Flex gap={8} align="center">
|
||||||
|
|||||||
@@ -21,8 +21,19 @@ const columns: TableColumnsType<PedidoSummary> = [
|
|||||||
</Link>
|
</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',
|
title: 'Total',
|
||||||
dataIndex: 'total',
|
dataIndex: 'total',
|
||||||
|
|||||||
@@ -49,8 +49,19 @@ const queueColumns: TableColumnsType<PedidoSummary> = [
|
|||||||
</Link>
|
</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',
|
title: 'Total',
|
||||||
dataIndex: 'total',
|
dataIndex: 'total',
|
||||||
@@ -263,7 +274,12 @@ export function SupervisorPainel() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Space orientation="vertical" size={0}>
|
<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>
|
</Space>
|
||||||
<Tag
|
<Tag
|
||||||
color={r.inativosCount >= 3 ? 'orange' : 'default'}
|
color={r.inativosCount >= 3 ? 'orange' : 'default'}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Button, Flex, Tooltip } from 'antd';
|
import { Alert, Button, Flex, Tooltip } from 'antd';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Topbar } from './Topbar';
|
import { Topbar } from './Topbar';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
||||||
|
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -15,12 +17,23 @@ interface AppShellProps {
|
|||||||
* Variante mobile (Rafael) com bottom nav virá em ShellMobile separado.
|
* Variante mobile (Rafael) com bottom nav virá em ShellMobile separado.
|
||||||
*/
|
*/
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
const [, setSidebarOpen] = useState(true);
|
||||||
const [_sidebarOpen, setSidebarOpen] = useState(true);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isOnline = useNetworkStatus();
|
||||||
|
useOfflineSync();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
|
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
|
||||||
|
{!isOnline && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
icon={<WifiOutlined />}
|
||||||
|
showIcon
|
||||||
|
banner
|
||||||
|
message="Sem conexão — pedidos lançados ficam salvos e serão enviados ao reconectar"
|
||||||
|
style={{ padding: '6px 16px', fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
|
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
|
||||||
<Flex flex={1}>
|
<Flex flex={1}>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Badge, Menu } from 'antd';
|
import { Menu } from 'antd';
|
||||||
import { useLocation, useNavigate } from '@tanstack/react-router';
|
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
@@ -11,10 +11,8 @@ import {
|
|||||||
faGear,
|
faGear,
|
||||||
faPercent,
|
faPercent,
|
||||||
faFileInvoiceDollar,
|
faFileInvoiceDollar,
|
||||||
faCheckCircle,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import type { ItemType } from 'antd/es/menu/interface';
|
import type { ItemType } from 'antd/es/menu/interface';
|
||||||
import { useOrderList } from '../../lib/queries/orders';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar canônica do SAR (260px fixa — brand.md).
|
* Sidebar canônica do SAR (260px fixa — brand.md).
|
||||||
@@ -23,9 +21,6 @@ import { useOrderList } from '../../lib/queries/orders';
|
|||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data: pendingOrders } = useOrderList({ status: 'pending_approval', limit: 1 });
|
|
||||||
const pendingCount = pendingOrders?.total ?? 0;
|
|
||||||
|
|
||||||
const items: ItemType[] = [
|
const items: ItemType[] = [
|
||||||
{
|
{
|
||||||
key: '/',
|
key: '/',
|
||||||
@@ -57,18 +52,6 @@ export function Sidebar() {
|
|||||||
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
||||||
label: 'Pedidos',
|
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',
|
key: '/comissao',
|
||||||
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,
|
||||||
|
|||||||
18
apps/web/src/lib/hooks/useNetworkStatus.ts
Normal file
18
apps/web/src/lib/hooks/useNetworkStatus.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useNetworkStatus(): boolean {
|
||||||
|
const [isOnline, setIsOnline] = useState(() => navigator.onLine);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const up = () => setIsOnline(true);
|
||||||
|
const down = () => setIsOnline(false);
|
||||||
|
window.addEventListener('online', up);
|
||||||
|
window.addEventListener('offline', down);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', up);
|
||||||
|
window.removeEventListener('offline', down);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isOnline;
|
||||||
|
}
|
||||||
63
apps/web/src/lib/hooks/useOfflineSync.ts
Normal file
63
apps/web/src/lib/hooks/useOfflineSync.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Auto-sync da fila offline ao recuperar conexão.
|
||||||
|
// NFR-2.3: detecta retorno de conexão e sincroniza sem ação do usuário.
|
||||||
|
// NFR-2.4: falhas visíveis — nunca descarta pedido silenciosamente.
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
listPendingOrders,
|
||||||
|
removePendingOrder,
|
||||||
|
markOrderFailed,
|
||||||
|
type PendingOrder,
|
||||||
|
} from '../offline/order-queue';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
export function useOfflineSync() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const sync = useCallback(async () => {
|
||||||
|
const pending: PendingOrder[] = await listPendingOrders();
|
||||||
|
const toSync = pending.filter((o: PendingOrder) => o.status === 'pending');
|
||||||
|
if (toSync.length === 0) return;
|
||||||
|
|
||||||
|
for (const order of toSync as PendingOrder[]) {
|
||||||
|
try {
|
||||||
|
const created = (await apiFetch('/orders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: order.payload,
|
||||||
|
})) as { id: string };
|
||||||
|
|
||||||
|
// Tenta transmitir — bloqueio duro se acima da alçada; deixa como Orçamento
|
||||||
|
try {
|
||||||
|
await apiFetch(`/orders/${created.id}/transmit`, { method: 'PATCH' });
|
||||||
|
} catch {
|
||||||
|
// Desconto acima da alçada: pedido fica como Orçamento, rep transmite manualmente
|
||||||
|
}
|
||||||
|
|
||||||
|
await removePendingOrder(order.idempotencyKey);
|
||||||
|
} catch (e) {
|
||||||
|
const reason = e instanceof Error ? e.message : 'Erro ao sincronizar pedido';
|
||||||
|
await markOrderFailed(order.idempotencyKey, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifica UI para re-render das listas
|
||||||
|
window.dispatchEvent(new CustomEvent('sar:sync-complete'));
|
||||||
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
}, [qc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Sync imediato no mount se houver fila e conexão
|
||||||
|
if (navigator.onLine) void sync();
|
||||||
|
|
||||||
|
const handleOnline = () => void sync();
|
||||||
|
const handleRequest = () => void sync();
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('sar:sync-request', handleRequest);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('sar:sync-request', handleRequest);
|
||||||
|
};
|
||||||
|
}, [sync]);
|
||||||
|
}
|
||||||
27
apps/web/src/lib/hooks/usePendingOrders.ts
Normal file
27
apps/web/src/lib/hooks/usePendingOrders.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { listPendingOrders, type PendingOrder } from '../offline/order-queue';
|
||||||
|
|
||||||
|
export function usePendingOrders() {
|
||||||
|
const [orders, setOrders] = useState<PendingOrder[]>([]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const pending = await listPendingOrders();
|
||||||
|
setOrders(pending);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
const handle = () => void refresh();
|
||||||
|
window.addEventListener('sar:sync-complete', handle);
|
||||||
|
window.addEventListener('sar:sync-request', handle);
|
||||||
|
window.addEventListener('sar:order-queued', handle);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('sar:sync-complete', handle);
|
||||||
|
window.removeEventListener('sar:sync-request', handle);
|
||||||
|
window.removeEventListener('sar:order-queued', handle);
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { orders, refresh };
|
||||||
|
}
|
||||||
54
apps/web/src/lib/offline/idb.ts
Normal file
54
apps/web/src/lib/offline/idb.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Wrappers mínimos sobre IndexedDB nativo — sem dependências externas.
|
||||||
|
// Todos os stores do SAR offline vivem em um único banco versionado.
|
||||||
|
|
||||||
|
const DB_NAME = 'sar-offline';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
|
export const STORE_PENDING_ORDERS = 'pending-orders';
|
||||||
|
|
||||||
|
let _db: IDBDatabase | null = null;
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
if (_db) return Promise.resolve(_db);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
req.onupgradeneeded = (e) => {
|
||||||
|
const db = (e.target as IDBOpenDBRequest).result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_PENDING_ORDERS)) {
|
||||||
|
db.createObjectStore(STORE_PENDING_ORDERS, { keyPath: 'idempotencyKey' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = () => {
|
||||||
|
_db = req.result;
|
||||||
|
resolve(_db);
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbGetAll<T>(store: string): Promise<T[]> {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = db.transaction(store, 'readonly').objectStore(store).getAll();
|
||||||
|
req.onsuccess = () => resolve(req.result as T[]);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbPut<T>(store: string, value: T): Promise<void> {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = db.transaction(store, 'readwrite').objectStore(store).put(value);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idbDelete(store: string, key: string): Promise<void> {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = db.transaction(store, 'readwrite').objectStore(store).delete(key);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
63
apps/web/src/lib/offline/order-queue.ts
Normal file
63
apps/web/src/lib/offline/order-queue.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Fila de pedidos pendentes de sync (IndexedDB).
|
||||||
|
// FR-4.2: lançamento funciona completamente offline.
|
||||||
|
// FR-4.3: Idempotency-Key gerado localmente antes do envio.
|
||||||
|
// FR-4.11: falhas de sync nunca descartadas silenciosamente.
|
||||||
|
|
||||||
|
import type { CreatePedido } from '@sar/api-interface';
|
||||||
|
import { idbGetAll, idbPut, idbDelete, STORE_PENDING_ORDERS } from './idb';
|
||||||
|
|
||||||
|
export interface PendingOrder {
|
||||||
|
idempotencyKey: string; // keyPath do IndexedDB
|
||||||
|
payload: CreatePedido;
|
||||||
|
clienteNome: string;
|
||||||
|
status: 'pending' | 'failed';
|
||||||
|
failReason?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPendingOrders(): Promise<PendingOrder[]> {
|
||||||
|
return idbGetAll<PendingOrder>(STORE_PENDING_ORDERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueOrder(
|
||||||
|
payload: CreatePedido,
|
||||||
|
clienteNome: string,
|
||||||
|
): Promise<PendingOrder> {
|
||||||
|
const key = payload.idempotencyKey ?? crypto.randomUUID();
|
||||||
|
const order: PendingOrder = {
|
||||||
|
idempotencyKey: key,
|
||||||
|
payload: { ...payload, idempotencyKey: key },
|
||||||
|
clienteNome,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await idbPut<PendingOrder>(STORE_PENDING_ORDERS, order);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePendingOrder(idempotencyKey: string): Promise<void> {
|
||||||
|
return idbDelete(STORE_PENDING_ORDERS, idempotencyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markOrderFailed(idempotencyKey: string, reason: string): Promise<void> {
|
||||||
|
const all = await listPendingOrders();
|
||||||
|
const order = all.find((o) => o.idempotencyKey === idempotencyKey);
|
||||||
|
if (!order) return;
|
||||||
|
await idbPut<PendingOrder>(STORE_PENDING_ORDERS, {
|
||||||
|
...order,
|
||||||
|
status: 'failed',
|
||||||
|
failReason: reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryPendingOrder(idempotencyKey: string): Promise<void> {
|
||||||
|
const all = await listPendingOrders();
|
||||||
|
const order = all.find((o) => o.idempotencyKey === idempotencyKey);
|
||||||
|
if (!order) return;
|
||||||
|
await idbPut<PendingOrder>(STORE_PENDING_ORDERS, {
|
||||||
|
...order,
|
||||||
|
status: 'pending',
|
||||||
|
failReason: undefined,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new CustomEvent('sar:sync-request'));
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
FormaPagamentoSchema,
|
||||||
PautaSchema,
|
PautaSchema,
|
||||||
ProdutoListResponseSchema,
|
ProdutoListResponseSchema,
|
||||||
ProdutoDetailSchema,
|
ProdutoDetailSchema,
|
||||||
|
type FormaPagamento,
|
||||||
type ProdutoListQuery,
|
type ProdutoListQuery,
|
||||||
type ProdutoListResponse,
|
type ProdutoListResponse,
|
||||||
type ProdutoDetail,
|
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> = {}) {
|
export function useCatalog(params: Partial<ProdutoListQuery> = {}) {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
if (params.q) search.set('q', params.q);
|
if (params.q) search.set('q', params.q);
|
||||||
|
|||||||
12
apps/web/src/lib/queries/company.ts
Normal file
12
apps/web/src/lib/queries/company.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { EmpresaInfoSchema, type EmpresaInfo } from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
// Dados da empresa matriz (cabeçalho do PDF do pedido). Cache longo — muda raramente.
|
||||||
|
export function useCompany() {
|
||||||
|
return useQuery<EmpresaInfo, Error>({
|
||||||
|
queryKey: ['company'],
|
||||||
|
queryFn: async () => EmpresaInfoSchema.parse(await apiFetch('/catalog/company')),
|
||||||
|
staleTime: 1000 * 60 * 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { ClientsPage } from '../cockpits/rep/ClientsPage';
|
|||||||
import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage';
|
import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage';
|
||||||
import { OrdersPage } from '../cockpits/rep/OrdersPage';
|
import { OrdersPage } from '../cockpits/rep/OrdersPage';
|
||||||
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
|
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
|
||||||
|
import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
|
||||||
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
||||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||||
@@ -98,6 +99,12 @@ const pedidoDetailRoute = createRoute({
|
|||||||
component: OrderDetailPage,
|
component: OrderDetailPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pedidoPrintRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/pedidos/$id/imprimir',
|
||||||
|
component: OrderPrintPage,
|
||||||
|
});
|
||||||
|
|
||||||
const catalogoRoute = createRoute({
|
const catalogoRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/catalogo',
|
path: '/catalogo',
|
||||||
@@ -118,6 +125,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
pedidosRoute,
|
pedidosRoute,
|
||||||
novoOrderRoute,
|
novoOrderRoute,
|
||||||
pedidoDetailRoute,
|
pedidoDetailRoute,
|
||||||
|
pedidoPrintRoute,
|
||||||
catalogoRoute,
|
catalogoRoute,
|
||||||
aprovacoes,
|
aprovacoes,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export * from './lib/order.contract';
|
|||||||
export * from './lib/product.contract';
|
export * from './lib/product.contract';
|
||||||
export * from './lib/dashboard.contract';
|
export * from './lib/dashboard.contract';
|
||||||
export * from './lib/notifications.contract';
|
export * from './lib/notifications.contract';
|
||||||
|
export * from './lib/company.contract';
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const ClientSummarySchema = z.object({
|
|||||||
email: z.string().nullable(),
|
email: z.string().nullable(),
|
||||||
telefone: z.string().nullable(),
|
telefone: z.string().nullable(),
|
||||||
codVendedor: z.number().int(),
|
codVendedor: z.number().int(),
|
||||||
|
nomeVendedor: z.string().nullable().optional(),
|
||||||
limiteCreditoStr: z.string().nullable(),
|
limiteCreditoStr: z.string().nullable(),
|
||||||
activityStatus: ActivityStatusSchema,
|
activityStatus: ActivityStatusSchema,
|
||||||
dtUltimaCompra: z.iso.datetime().nullable(),
|
dtUltimaCompra: z.iso.datetime().nullable(),
|
||||||
|
|||||||
21
libs/shared/api-interface/src/lib/company.contract.ts
Normal file
21
libs/shared/api-interface/src/lib/company.contract.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Dados legais da empresa matriz (a que fatura o pedido) — cabeçalho do PDF.
|
||||||
|
// Fonte: gestao.empresa (matriz) + vw_municipios.
|
||||||
|
export const EmpresaInfoSchema = z.object({
|
||||||
|
idEmpresa: z.number().int(),
|
||||||
|
razaoSocial: z.string(),
|
||||||
|
nomeFantasia: z.string().nullable(),
|
||||||
|
cnpj: z.string().nullable(),
|
||||||
|
inscricaoEstadual: z.string().nullable(),
|
||||||
|
endereco: z.string().nullable(),
|
||||||
|
numero: z.string().nullable(),
|
||||||
|
complemento: z.string().nullable(),
|
||||||
|
bairro: z.string().nullable(),
|
||||||
|
cidade: z.string().nullable(),
|
||||||
|
uf: z.string().nullable(),
|
||||||
|
cep: z.string().nullable(),
|
||||||
|
telefone: z.string().nullable(),
|
||||||
|
email: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type EmpresaInfo = z.infer<typeof EmpresaInfoSchema>;
|
||||||
@@ -11,6 +11,31 @@ export const ClienteInativoSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type ClienteInativo = z.infer<typeof ClienteInativoSchema>;
|
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({
|
export const RepDashboardSchema = z.object({
|
||||||
meta: z.object({
|
meta: z.object({
|
||||||
atingido: z.number(),
|
atingido: z.number(),
|
||||||
@@ -18,6 +43,9 @@ export const RepDashboardSchema = z.object({
|
|||||||
pct: z.number(),
|
pct: z.number(),
|
||||||
falta: 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({
|
comissao: z.object({
|
||||||
fixa: z.number(),
|
fixa: z.number(),
|
||||||
flex: z.number(),
|
flex: z.number(),
|
||||||
@@ -32,6 +60,7 @@ export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
|||||||
|
|
||||||
export const RepInativosSummarySchema = z.object({
|
export const RepInativosSummarySchema = z.object({
|
||||||
codVendedor: z.number().int(),
|
codVendedor: z.number().int(),
|
||||||
|
nomeVendedor: z.string().nullable().optional(),
|
||||||
inativosCount: z.number().int(),
|
inativosCount: z.number().int(),
|
||||||
});
|
});
|
||||||
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
|
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
|
||||||
|
|||||||
@@ -6,13 +6,24 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
// ─── Situa ────────────────────────────────────────────────────────────────────
|
// ─── Situa ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// situa: 1=Pendente 2=Aprovado 3=Cancelado 4=Faturado
|
// Ciclo de vida do pedido SAR:
|
||||||
export const SituaPedidoSchema = z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]);
|
// 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 type SituaPedido = z.infer<typeof SituaPedidoSchema>;
|
||||||
|
|
||||||
export const SITUA_LABEL: Record<number, string> = {
|
export const SITUA_LABEL: Record<number, string> = {
|
||||||
|
0: 'Orçamento',
|
||||||
1: 'Ag. Aprovação',
|
1: 'Ag. Aprovação',
|
||||||
2: 'Aprovado',
|
2: 'Transmitido',
|
||||||
3: 'Cancelado',
|
3: 'Cancelado',
|
||||||
4: 'Faturado',
|
4: 'Faturado',
|
||||||
};
|
};
|
||||||
@@ -54,6 +65,7 @@ export const PedidoSummarySchema = z.object({
|
|||||||
nomeCliente: z.string().nullable().optional(),
|
nomeCliente: z.string().nullable().optional(),
|
||||||
razaoCliente: z.string().nullable().optional(),
|
razaoCliente: z.string().nullable().optional(),
|
||||||
codVendedor: z.number().int(),
|
codVendedor: z.number().int(),
|
||||||
|
nomeVendedor: z.string().nullable().optional(),
|
||||||
situa: z.number().int(),
|
situa: z.number().int(),
|
||||||
statusDescr: z.string().optional(), // descrição legível do status
|
statusDescr: z.string().optional(), // descrição legível do status
|
||||||
dtPedido: z.string(),
|
dtPedido: z.string(),
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ export const PautaSchema = z.object({
|
|||||||
descricao: z.string(),
|
descricao: z.string(),
|
||||||
});
|
});
|
||||||
export type Pauta = z.infer<typeof PautaSchema>;
|
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>;
|
export type ProdutoSummary = z.infer<typeof ProdutoSummarySchema>;
|
||||||
|
|
||||||
// ─── Produto Detail ───────────────────────────────────────────────────────────
|
// ─── Produto Detail ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user