refactor(erp): integração direta com banco ERP — schema sar
Revoga ADR 0006 (BD-por-workspace separado). O SAR agora conecta ao banco PostgreSQL do ERP (módulo SIG) e usa o schema `sar` para tudo. PRISMA - Remove: Client, Product, Order, OrderItem, OrderStatusHistory, RepTarget, RepDiscountLimit, PushSubscription (modelos isolados) - Adiciona: Pedido, PedidoItem, HistoricoPedido, AlcadaDesconto, MetaRepresentante, PushSubscription (mapeados para sar.*) - IDs: id_cliente/cod_vendedor/id_empresa são INTEGER (ERP) - situa: Int (1=Pendente 2=Aprovado 3=Cancelado 4=Faturado) - JWT: workspace_id:string → id_empresa:number - URL: inclui ?schema=sar para Prisma rotear ao schema ERP SERVICES - ClientsService: $queryRawUnsafe contra sar.vw_clientes + sar.pedidos - CatalogService: $queryRawUnsafe contra sar.vw_produtos + sar.vw_estoque - OrdersService: Prisma models Pedido/PedidoItem/HistoricoPedido/AlcadaDesconto - DashboardService: MetaRepresentante + queries raw para inativos - NotificationsService: PushSubscription com codVendedor + idEmpresa CONTRATOS (api-interface) - client.contract: campos ERP (idCliente, nome, cgcpf, cod_vendedor…) - order.contract: PedidoSummary/PedidoDetail/CreatePedido + SITUA_LABEL - product.contract: ProdutoSummary/ProdutoDetail (vw_produtos) - auth.contract: workspaceId:string → idEmpresa:number WEB - Todos os cockpits e queries atualizados para os novos tipos Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,10 @@
|
|||||||
// SAR — Workspace Database Schema
|
// SAR — Schema no banco ERP da JCS (schema `sar` dentro do PostgreSQL do SIG/gestao)
|
||||||
// Stack canon: Prisma 7 · PostgreSQL 18 · BD-por-workspace (ADR 0006)
|
// ADR 0006 revogado: banco separado por workspace → schema `sar` no ERP JCS.
|
||||||
//
|
// O isolamento multi-tenant é por `id_empresa` em todas as tabelas.
|
||||||
// Este schema roda em CADA workspace DB (sar_workspace_<id>).
|
|
||||||
// NÃO há workspaceId/tenantId em nenhum modelo — o isolamento é físico.
|
|
||||||
// O banco master (sar_master) é gerenciado pelo master-login (IdP JCS), não por este schema.
|
|
||||||
//
|
//
|
||||||
// CODING-RULES PGD-DB-004: moduleFormat = "cjs" (NestJS é CJS)
|
// CODING-RULES PGD-DB-004: moduleFormat = "cjs" (NestJS é CJS)
|
||||||
// CODING-RULES PGD-DB-001: MIGRATION_DATABASE_URL aponta direto ao PG (sem PgBouncer)
|
// CODING-RULES PGD-DB-001: MIGRATION_DATABASE_URL aponta direto ao PG (sem PgBouncer)
|
||||||
|
// A URL de runtime deve incluir ?schema=sar (injetado pelo JwtAuthGuard via WorkspacePrismaPool)
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
@@ -16,232 +14,156 @@ generator client {
|
|||||||
|
|
||||||
// Prisma 7: url removida do schema — conexão em prisma.config.ts (migrate)
|
// Prisma 7: url removida do schema — conexão em prisma.config.ts (migrate)
|
||||||
// e no WorkspacePrismaPool via PrismaPg adapter (runtime).
|
// e no WorkspacePrismaPool via PrismaPg adapter (runtime).
|
||||||
|
// A URL de runtime inclui ?schema=sar para rotear ao schema correto.
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Enums ───────────────────────────────────────────────────────────────────
|
// ─── Pedido (C3) ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Situação financeira resumida do cliente — cacheável offline (FR-2.4, FR-2.5).
|
|
||||||
enum FinancialStatus {
|
|
||||||
regular
|
|
||||||
attention
|
|
||||||
blocked
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status do pedido (FR-3.2). Transições: budget → pending_approval → approved → invoiced.
|
|
||||||
// Qualquer status pode ir para cancelled.
|
|
||||||
enum OrderStatus {
|
|
||||||
budget // orçamento
|
|
||||||
pending_approval // aprovação pendente
|
|
||||||
approved // aprovado
|
|
||||||
invoiced // faturado
|
|
||||||
cancelled // cancelado
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Client (C2) ─────────────────────────────────────────────────────────────
|
|
||||||
//
|
//
|
||||||
// Cadastro sincronizado do ERP legado (FR-2.6). Rep não cria/edita no MVP.
|
// Pedido emitido pelo Rep. Situa: 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado.
|
||||||
// creditLimit: gerenciado no SAR (OQ-4 resolvido 2026-05-27).
|
// idEmpresa: tenant (empresa no ERP). codVendedor: gestao.vendedor.codigo.
|
||||||
// lastOrderAt/lastOrderValue/openOrdersCount: desnormalizados de Orders.
|
// idCliente: sig.corrent.id_corrent. numPedSar: sequencial SAR (SAR-NNNNN).
|
||||||
|
|
||||||
model Client {
|
model Pedido {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
idEmpresa Int @map("id_empresa")
|
||||||
tradeName String?
|
numPedSar String @unique @map("num_ped_sar")
|
||||||
taxId String @unique
|
idCliente Int @map("id_cliente")
|
||||||
email String?
|
codVendedor Int @map("cod_vendedor")
|
||||||
phone String?
|
situa Int @default(1)
|
||||||
address Json?
|
dtPedido DateTime @default(now()) @db.Date @map("dt_pedido")
|
||||||
|
idPauta Int? @map("id_pauta")
|
||||||
|
codFormapag Int? @map("cod_formapag")
|
||||||
|
totalProdutos Decimal @default(0) @db.Decimal(15, 2) @map("total_produtos")
|
||||||
|
totalIpi Decimal @default(0) @db.Decimal(15, 2) @map("total_ipi")
|
||||||
|
totalIcmsst Decimal @default(0) @db.Decimal(15, 2) @map("total_icmsst")
|
||||||
|
total Decimal @default(0) @db.Decimal(15, 2)
|
||||||
|
descontoPerc Decimal @default(0) @db.Decimal(5, 2) @map("desconto_perc")
|
||||||
|
descontoValor Decimal @default(0) @db.Decimal(15, 2) @map("desconto_valor")
|
||||||
|
acrescimo Decimal @default(0) @db.Decimal(15, 2)
|
||||||
|
comissao Decimal @default(0) @db.Decimal(15, 2)
|
||||||
|
pedFlex Decimal @default(0) @db.Decimal(15, 2) @map("ped_flex")
|
||||||
|
obs String?
|
||||||
|
aprovadoPor Int? @map("aprovado_por")
|
||||||
|
aprovadoEm DateTime? @map("aprovado_em")
|
||||||
|
motivoRecusa String? @map("motivo_recusa")
|
||||||
|
idempotencyKey String? @unique @map("idempotency_key")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
financialStatus FinancialStatus @default(regular)
|
itens PedidoItem[]
|
||||||
creditLimit Decimal? @db.Decimal(15, 2)
|
historico HistoricoPedido[]
|
||||||
|
|
||||||
repId String
|
@@index([idEmpresa])
|
||||||
lastOrderAt DateTime?
|
@@index([codVendedor])
|
||||||
lastOrderValue Decimal? @db.Decimal(15, 2)
|
@@index([idCliente])
|
||||||
openOrdersCount Int @default(0)
|
@@index([situa])
|
||||||
|
@@index([dtPedido])
|
||||||
erpCode String?
|
@@map("pedidos")
|
||||||
syncedAt DateTime?
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
deletedAt DateTime?
|
|
||||||
|
|
||||||
orders Order[]
|
|
||||||
|
|
||||||
@@index([repId])
|
|
||||||
@@index([taxId])
|
|
||||||
@@index([name])
|
|
||||||
@@index([deletedAt])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Order (C3) ──────────────────────────────────────────────────────────────
|
// ─── PedidoItem (C3/C4) ──────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Pedido emitido pelo Rep. Itens desnormalizados (produto sem FK — C4 traz catálogo).
|
// Item do pedido. Produto desnormalizado via idProduto (vw_produtos).
|
||||||
// number: gerado pelo SAR (sequencial por workspace, ex: "PED-00042").
|
|
||||||
// discountPct: desconto global do pedido (além de descontos por item).
|
|
||||||
// approvedById: userId de quem aprovou (se status = approved ou invoiced).
|
|
||||||
|
|
||||||
model Order {
|
model PedidoItem {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
number String @unique // "PED-00001"
|
idPedido String @db.Uuid @map("id_pedido")
|
||||||
clientId String @db.Uuid
|
ordem Int
|
||||||
repId String // userId do Rep que emitiu
|
idProduto Int @map("id_produto")
|
||||||
status OrderStatus @default(budget)
|
codProduto String? @map("cod_produto")
|
||||||
discountPct Decimal @default(0) @db.Decimal(5, 2) // % desconto global
|
descProduto String? @map("desc_produto")
|
||||||
subtotal Decimal @db.Decimal(15, 2) // soma dos itens sem desconto global
|
qtd Decimal @db.Decimal(10, 3)
|
||||||
total Decimal @db.Decimal(15, 2) // subtotal × (1 - discountPct/100)
|
precoUnitario Decimal @db.Decimal(15, 2) @map("preco_unitario")
|
||||||
notes String?
|
descontoPerc Decimal @default(0) @db.Decimal(5, 2) @map("desconto_perc")
|
||||||
approvedById String? // userId de quem aprovou
|
descontoValor Decimal @default(0) @db.Decimal(15, 2) @map("desconto_valor")
|
||||||
approvedAt DateTime?
|
precoPauta Decimal @default(0) @db.Decimal(15, 2) @map("preco_pauta")
|
||||||
invoicedAt DateTime?
|
comissao Decimal @default(0) @db.Decimal(15, 2)
|
||||||
cancelledAt DateTime?
|
vlFlex Decimal @default(0) @db.Decimal(15, 2) @map("vl_flex")
|
||||||
|
precoComIpi Decimal @default(0) @db.Decimal(15, 2) @map("preco_com_ipi")
|
||||||
|
vlIpi Decimal @default(0) @db.Decimal(15, 2) @map("vl_ipi")
|
||||||
|
vlIcmsst Decimal @default(0) @db.Decimal(15, 2) @map("vl_icmsst")
|
||||||
|
total Decimal @db.Decimal(15, 2)
|
||||||
|
|
||||||
// Idempotency key para lançamentos offline (C4, FR-4.12)
|
pedido Pedido @relation(fields: [idPedido], references: [id], onDelete: Cascade)
|
||||||
idempotencyKey String? @unique
|
|
||||||
|
|
||||||
issuedAt DateTime @default(now()) // data de emissão pelo Rep
|
@@index([idPedido])
|
||||||
createdAt DateTime @default(now())
|
@@map("pedido_itens")
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
deletedAt DateTime?
|
|
||||||
|
|
||||||
client Client @relation(fields: [clientId], references: [id])
|
|
||||||
items OrderItem[]
|
|
||||||
history OrderStatusHistory[]
|
|
||||||
|
|
||||||
@@index([clientId])
|
|
||||||
@@index([repId])
|
|
||||||
@@index([status])
|
|
||||||
@@index([issuedAt])
|
|
||||||
@@index([number])
|
|
||||||
@@index([deletedAt])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── OrderItem (C3/C4) ───────────────────────────────────────────────────────
|
// ─── HistoricoPedido (C3) ────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Item do pedido. Produto desnormalizado (nome/código/categoria) — snapshot no momento do pedido.
|
// Registro imutável de cada transição de situa. changedBy = cod_vendedor do ator.
|
||||||
// productCategory: usado para validação de alçada por linha no POST /orders.
|
|
||||||
// discountPct: desconto por linha (além do desconto global do Order).
|
|
||||||
|
|
||||||
model OrderItem {
|
model HistoricoPedido {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
orderId String @db.Uuid
|
idPedido String @db.Uuid @map("id_pedido")
|
||||||
productCode String // código no ERP / catálogo
|
situaAnterior Int? @map("situa_anterior")
|
||||||
productName String // desnormalizado para exibição offline
|
situaNova Int @map("situa_nova")
|
||||||
productCategory String @default("geral") // desnormalizado para alçada por linha
|
changedBy Int @map("changed_by")
|
||||||
quantity Decimal @db.Decimal(10, 3)
|
nota String?
|
||||||
unitPrice Decimal @db.Decimal(15, 2)
|
changedAt DateTime @default(now()) @map("changed_at")
|
||||||
discountPct Decimal @default(0) @db.Decimal(5, 2)
|
|
||||||
subtotal Decimal @db.Decimal(15, 2) // qty × unitPrice × (1 - discountPct/100)
|
|
||||||
|
|
||||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
pedido Pedido @relation(fields: [idPedido], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([orderId])
|
@@index([idPedido])
|
||||||
|
@@map("historico_pedido")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Product (C4) ────────────────────────────────────────────────────────────
|
// ─── AlcadaDesconto (C4) ─────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Catálogo sincronizado da view do ERP (FR-4.4). Rep usa para montar pedido.
|
// Alçada de desconto por vendedor, empresa e grupo de produto.
|
||||||
// category: agrupa produtos por linha para validação de alçada por linha (OQ-2).
|
// codGrupo = 0 → limite global/default do rep.
|
||||||
// unitPrice/stock: snapshot da última sync (TTL 4h — FR-4.4 [ASSUMPTION]).
|
|
||||||
// Produto inativo (active=false) não aparece no catálogo mas histórico de pedidos mantém referência.
|
|
||||||
|
|
||||||
model Product {
|
model AlcadaDesconto {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
codVendedor Int @map("cod_vendedor")
|
||||||
code String @unique
|
idEmpresa Int @map("id_empresa")
|
||||||
name String
|
codGrupo Int @default(0) @map("cod_grupo")
|
||||||
description String?
|
limitePerc Decimal @default(5) @db.Decimal(5, 2) @map("limite_perc")
|
||||||
category String @default("geral")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
unitPrice Decimal @db.Decimal(15, 2)
|
|
||||||
stock Decimal? @db.Decimal(10, 3)
|
|
||||||
active Boolean @default(true)
|
|
||||||
erpCode String?
|
|
||||||
syncedAt DateTime?
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
@@id([codVendedor, idEmpresa, codGrupo])
|
||||||
updatedAt DateTime @updatedAt
|
@@index([codVendedor, idEmpresa])
|
||||||
deletedAt DateTime?
|
@@map("alcada_desconto")
|
||||||
|
|
||||||
@@index([code])
|
|
||||||
@@index([name])
|
|
||||||
@@index([category])
|
|
||||||
@@index([active])
|
|
||||||
@@index([deletedAt])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RepTarget (C7) ──────────────────────────────────────────────────────────
|
// ─── MetaRepresentante (C7) ──────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Meta mensal e taxas de comissão por rep. Uma linha por rep/mês.
|
// Meta mensal e taxas de comissão por rep. Uma linha por rep/empresa/mês.
|
||||||
// commissionRate: % aplicada sobre o total aprovado+faturado do mês.
|
|
||||||
// flexRate: % bônus adicional quando atingido >= targetAmount.
|
|
||||||
|
|
||||||
model RepTarget {
|
model MetaRepresentante {
|
||||||
repId String
|
codVendedor Int @map("cod_vendedor")
|
||||||
year Int
|
idEmpresa Int @map("id_empresa")
|
||||||
month Int // 1–12
|
ano Int
|
||||||
targetAmount Decimal @db.Decimal(15, 2)
|
mes Int
|
||||||
commissionRate Decimal @default(3) @db.Decimal(5, 2)
|
metaValor Decimal @db.Decimal(15, 2) @map("meta_valor")
|
||||||
flexRate Decimal @default(1) @db.Decimal(5, 2)
|
taxaComissao Decimal @default(3) @db.Decimal(5, 2) @map("taxa_comissao")
|
||||||
|
taxaFlex Decimal @default(1) @db.Decimal(5, 2) @map("taxa_flex")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
updatedAt DateTime @updatedAt
|
@@id([codVendedor, idEmpresa, ano, mes])
|
||||||
|
@@index([codVendedor, idEmpresa])
|
||||||
@@id([repId, year, month])
|
@@map("meta_representante")
|
||||||
@@index([repId])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── RepDiscountLimit (C4) ───────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Alçada de desconto por rep e por linha de produto (OQ-2 resolvida 2026-05-27).
|
|
||||||
// category = "__default__" → limite global do rep (fallback quando linha não tem override).
|
|
||||||
// Lookup: (repId, category) → se não encontrado → (repId, "__default__") → senão 5%.
|
|
||||||
|
|
||||||
model RepDiscountLimit {
|
|
||||||
repId String
|
|
||||||
category String // "__default__" para limite global; category string para override por linha
|
|
||||||
limit Decimal @default(5) @db.Decimal(5, 2)
|
|
||||||
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@id([repId, category])
|
|
||||||
@@index([repId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
|
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
|
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
|
||||||
// role desnormalizado do JWT para filtrar destinatários (notifySupervisors, notifyUser).
|
// codVendedor desnormalizado do JWT para filtrar destinatários.
|
||||||
|
|
||||||
model PushSubscription {
|
model PushSubscription {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String
|
codVendedor Int? @map("cod_vendedor")
|
||||||
role String // 'rep' | 'supervisor' | 'manager' | 'admin'
|
idEmpresa Int @map("id_empresa")
|
||||||
endpoint String @unique
|
role String
|
||||||
p256dh String
|
endpoint String @unique
|
||||||
auth String
|
p256dh String
|
||||||
createdAt DateTime @default(now())
|
auth String
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@index([userId])
|
@@index([idEmpresa])
|
||||||
@@index([role])
|
@@index([codVendedor])
|
||||||
}
|
@@map("push_subscription")
|
||||||
|
|
||||||
// ─── OrderStatusHistory (C3) ─────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Registro imutável de cada transição de status. changedById = userId do ator.
|
|
||||||
|
|
||||||
model OrderStatusHistory {
|
|
||||||
id String @id @default(uuid()) @db.Uuid
|
|
||||||
orderId String @db.Uuid
|
|
||||||
fromStatus OrderStatus?
|
|
||||||
toStatus OrderStatus
|
|
||||||
changedById String // userId
|
|
||||||
note String?
|
|
||||||
changedAt DateTime @default(now())
|
|
||||||
|
|
||||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([orderId])
|
|
||||||
@@index([changedAt])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ class DevTokenRequestDto extends createZodDto(DevTokenRequestSchema) {}
|
|||||||
|
|
||||||
// Dev-only stub — emite JWT HS256 para smoke tests locais.
|
// Dev-only stub — emite JWT HS256 para smoke tests locais.
|
||||||
// CODING-RULES PGD-SEC-002: retorna 404 em produção.
|
// CODING-RULES PGD-SEC-002: retorna 404 em produção.
|
||||||
// CODING-RULES PGD-AUTHZ-002: workspace_id vem do body aqui APENAS porque
|
// CODING-RULES PGD-AUTHZ-002: id_empresa vem do body aqui APENAS porque
|
||||||
// este endpoint É o gerador do token — nenhum outro handler pode fazer isso.
|
// este endpoint É o gerador do token — nenhum outro handler pode fazer isso.
|
||||||
|
// ADR 0006 revogado: workspaceId → idEmpresa (Int da empresa no ERP)
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Controller({ path: 'auth/dev' })
|
@Controller({ path: 'auth/dev' })
|
||||||
@@ -32,7 +33,7 @@ export class DevAuthController {
|
|||||||
if (this.isProd) throw new NotFoundException();
|
if (this.isProd) throw new NotFoundException();
|
||||||
|
|
||||||
const accessToken = await new SignJWT({
|
const accessToken = await new SignJWT({
|
||||||
workspace_id: dto.workspaceId,
|
id_empresa: dto.idEmpresa,
|
||||||
role: dto.role,
|
role: dto.role,
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import { WorkspacePrismaPool } from '../workspace/workspace-prisma-pool.service'
|
|||||||
import type { JwtPayload } from './jwt.types';
|
import type { JwtPayload } from './jwt.types';
|
||||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||||
|
|
||||||
// Guard global (APP_GUARD). Valida Bearer HS256 e atualiza CLS com workspace real.
|
// Guard global (APP_GUARD). Valida Bearer HS256 e atualiza CLS com idEmpresa real.
|
||||||
// CODING-RULES PGD-AUTHZ-002: workspaceId sempre do JWT, nunca de body/param.
|
// CODING-RULES PGD-AUTHZ-002: idEmpresa sempre do JWT, nunca de body/param.
|
||||||
// Ordem NestJS: middleware CLS (workspace default) → este guard (workspace real).
|
// Ordem NestJS: middleware CLS (idEmpresa default) → este guard (idEmpresa real).
|
||||||
|
// ADR 0006 revogado: workspace_id → id_empresa; URL inclui ?schema=sar
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard implements CanActivate {
|
export class JwtAuthGuard implements CanActivate {
|
||||||
@@ -44,16 +45,18 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
|
|
||||||
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
|
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
|
||||||
|
|
||||||
// Sobrescreve CLS com workspace real do JWT (corre depois do middleware).
|
// Sobrescreve CLS com idEmpresa real do JWT (corre depois do middleware).
|
||||||
const workspaceId = payload.workspace_id;
|
const idEmpresa = payload.id_empresa;
|
||||||
this.cls.set('workspaceId', workspaceId);
|
this.cls.set('idEmpresa', idEmpresa);
|
||||||
this.cls.set('userId', payload.sub);
|
this.cls.set('userId', payload.sub);
|
||||||
this.cls.set('role', payload.role);
|
this.cls.set('role', payload.role);
|
||||||
|
|
||||||
const dbUrl =
|
// URL inclui ?schema=sar para o Prisma rotear ao schema correto no ERP
|
||||||
|
const baseUrl =
|
||||||
process.env['DATABASE_URL'] ??
|
process.env['DATABASE_URL'] ??
|
||||||
`postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_${workspaceId}`;
|
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev';
|
||||||
this.cls.set('prisma', this.pool.getOrCreate(workspaceId, dbUrl));
|
const dbUrl = baseUrl.includes('?') ? `${baseUrl}&schema=sar` : `${baseUrl}?schema=sar`;
|
||||||
|
this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, dbUrl));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// Claims do JWT emitido pelo master-login. Fonte da verdade para req.user.
|
// Claims do JWT emitido pelo master-login. Fonte da verdade para req.user.
|
||||||
// CODING-RULES PGD-AUTHZ-002: workspace_id vem sempre do token, nunca do body.
|
// CODING-RULES PGD-AUTHZ-002: id_empresa vem sempre do token, nunca do body.
|
||||||
|
// ADR 0006 revogado: workspace_id → id_empresa (Int, empresa no ERP).
|
||||||
|
|
||||||
export type JwtRole = 'rep' | 'supervisor' | 'manager' | 'admin';
|
export type JwtRole = 'rep' | 'supervisor' | 'manager' | 'admin';
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
sub: string; // userId
|
sub: string; // userId / cod_vendedor como string
|
||||||
workspace_id: string;
|
id_empresa: number; // empresa no ERP (era workspace_id)
|
||||||
role: JwtRole;
|
role: JwtRole;
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
|
|||||||
@@ -1,48 +1,31 @@
|
|||||||
import {
|
import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
ParseUUIDPipe,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
ProductListQuerySchema,
|
ProdutoListQuerySchema,
|
||||||
ProductSyncRequestSchema,
|
type ProdutoDetail,
|
||||||
type ProductDetail,
|
type ProdutoListQuery,
|
||||||
type ProductListQuery,
|
type ProdutoListResponse,
|
||||||
type ProductListResponse,
|
|
||||||
type ProductSyncRequest,
|
|
||||||
type ProductSyncResponse,
|
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { CatalogService } from './catalog.service';
|
import { CatalogService } from './catalog.service';
|
||||||
|
|
||||||
class ProductListQueryDto extends createZodDto(ProductListQuerySchema) {}
|
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
|
||||||
class ProductSyncRequestDto extends createZodDto(ProductSyncRequestSchema) {}
|
|
||||||
|
// ADR 0006 revogado: UUID → Int para ID de produto. Sync removido (ERP direto via view).
|
||||||
|
|
||||||
@Controller({ path: 'catalog' })
|
@Controller({ path: 'catalog' })
|
||||||
export class CatalogController {
|
export class CatalogController {
|
||||||
constructor(private readonly catalog: CatalogService) {}
|
constructor(private readonly catalog: CatalogService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Query() query: ProductListQueryDto): Promise<ProductListResponse> {
|
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
|
||||||
const parsed = ProductListQuerySchema.parse(query) as ProductListQuery;
|
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;
|
||||||
return this.catalog.list(parsed);
|
return this.catalog.list(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ProductDetail> {
|
async findOne(@Param('id', ParseIntPipe) id: number): Promise<ProdutoDetail> {
|
||||||
const product = await this.catalog.findOne(id);
|
const product = await this.catalog.findOne(id);
|
||||||
if (!product) throw new NotFoundException(`Produto ${id} não encontrado`);
|
if (!product) throw new NotFoundException(`Produto ${id} não encontrado`);
|
||||||
return product;
|
return product;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync')
|
|
||||||
sync(@Body() body: ProductSyncRequestDto): Promise<ProductSyncResponse> {
|
|
||||||
const parsed = ProductSyncRequestSchema.parse(body) as ProductSyncRequest;
|
|
||||||
return this.catalog.sync(parsed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +1,188 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import type {
|
import type {
|
||||||
ProductDetail,
|
ProdutoDetail,
|
||||||
ProductListQuery,
|
ProdutoListQuery,
|
||||||
ProductListResponse,
|
ProdutoListResponse,
|
||||||
ProductSummary,
|
ProdutoSummary,
|
||||||
ProductSyncRequest,
|
|
||||||
ProductSyncResponse,
|
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
function decimalToString(v: Prisma.Decimal | null | undefined): string | null {
|
// ADR 0006 revogado: produtos lidos diretamente de vw_produtos (ERP) + vw_estoque.
|
||||||
return v ? v.toString() : null;
|
// Sem sync — dados sempre frescos da view do ERP.
|
||||||
|
|
||||||
|
function escSql(s: string): string {
|
||||||
|
return s.replace(/'/g, "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProdutoRow {
|
||||||
|
id_erp: number;
|
||||||
|
codigo: string;
|
||||||
|
descricao: string;
|
||||||
|
unidade: string | null;
|
||||||
|
vl_preco1: string;
|
||||||
|
cod_grupo: number | null;
|
||||||
|
grupo: string | null;
|
||||||
|
cod_subgrupo: number | null;
|
||||||
|
subgrupo: string | null;
|
||||||
|
marca: string | null;
|
||||||
|
ativo: number;
|
||||||
|
qtd_estoque: string | null;
|
||||||
|
lista_parauta: number | null;
|
||||||
|
referencia: string | null;
|
||||||
|
descricao_detalhada: string | null;
|
||||||
|
vl_preco2: string | null;
|
||||||
|
vl_preco3: string | null;
|
||||||
|
aliq_ipi: string | null;
|
||||||
|
peso_liquido: string | null;
|
||||||
|
qtd_volume: string | null;
|
||||||
|
lote_mul_venda: number | null;
|
||||||
|
preco_com_ipi: string | null;
|
||||||
|
preco_promocional: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CatalogService {
|
export class CatalogService {
|
||||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
async list(query: ProductListQuery): Promise<ProductListResponse> {
|
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 { q, category, page, limit } = query;
|
const { q, codGrupo, page, limit } = query;
|
||||||
const skip = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const where: Prisma.ProductWhereInput = {
|
const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
|
||||||
deletedAt: null,
|
const searchFilter = q
|
||||||
active: true,
|
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
|
||||||
...(category ? { category } : {}),
|
: '';
|
||||||
...(q
|
|
||||||
? {
|
|
||||||
OR: [
|
|
||||||
{ name: { contains: q, mode: 'insensitive' } },
|
|
||||||
{ code: { contains: q, mode: 'insensitive' } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const [rows, total] = await Promise.all([
|
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
|
||||||
prisma.product.findMany({
|
SELECT
|
||||||
where,
|
p.id_erp,
|
||||||
select: {
|
p.codigo,
|
||||||
id: true,
|
p.descricao,
|
||||||
code: true,
|
p.unidade,
|
||||||
name: true,
|
p.vl_preco1::text,
|
||||||
category: true,
|
p.cod_grupo,
|
||||||
unitPrice: true,
|
p.grupo,
|
||||||
stock: true,
|
p.cod_subgrupo,
|
||||||
active: true,
|
p.subgrupo,
|
||||||
},
|
p.marca,
|
||||||
skip,
|
p.ativo,
|
||||||
take: limit,
|
e.qtd_estoque::text,
|
||||||
orderBy: [{ category: 'asc' }, { name: 'asc' }],
|
p.lista_parauta,
|
||||||
}),
|
p.referencia,
|
||||||
prisma.product.count({ where }),
|
p.descricao_detalhada,
|
||||||
]);
|
p.vl_preco2::text,
|
||||||
|
p.vl_preco3::text,
|
||||||
|
p.aliq_ipi::text,
|
||||||
|
p.peso_liquido::text,
|
||||||
|
p.qtd_volume::text,
|
||||||
|
p.lote_mul_venda,
|
||||||
|
p.preco_com_ipi::text,
|
||||||
|
p.preco_promocional::text
|
||||||
|
FROM vw_produtos p
|
||||||
|
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
|
||||||
|
WHERE p.ativo = 1
|
||||||
|
${grupoFilter}
|
||||||
|
${searchFilter}
|
||||||
|
ORDER BY p.descricao
|
||||||
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
|
`);
|
||||||
|
|
||||||
const data: ProductSummary[] = rows.map((p) => ({
|
const totalRows = await prisma.$queryRawUnsafe<[{ count: string }]>(`
|
||||||
id: p.id,
|
SELECT COUNT(*)::text AS count
|
||||||
code: p.code,
|
FROM vw_produtos p
|
||||||
name: p.name,
|
WHERE p.ativo = 1
|
||||||
category: p.category,
|
${grupoFilter}
|
||||||
unitPrice: decimalToString(p.unitPrice) ?? '0',
|
${searchFilter}
|
||||||
stock: decimalToString(p.stock),
|
`);
|
||||||
active: p.active,
|
const total = parseInt(totalRows[0]?.count ?? '0', 10);
|
||||||
|
|
||||||
|
const data: ProdutoSummary[] = rows.map((p) => ({
|
||||||
|
idErp: Number(p.id_erp),
|
||||||
|
codigo: p.codigo,
|
||||||
|
descricao: p.descricao,
|
||||||
|
unidade: p.unidade,
|
||||||
|
vlPreco1: p.vl_preco1,
|
||||||
|
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
|
||||||
|
grupo: p.grupo,
|
||||||
|
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
|
||||||
|
subgrupo: p.subgrupo,
|
||||||
|
marca: p.marca,
|
||||||
|
ativo: Number(p.ativo),
|
||||||
|
qtdEstoque: p.qtd_estoque,
|
||||||
|
listaParauta: p.lista_parauta !== null ? Number(p.lista_parauta) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { data, total, page, limit };
|
return { data, total, page, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string): Promise<ProductDetail | 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 p = await prisma.product.findFirst({ where: { id, deletedAt: null, active: true } });
|
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
|
||||||
|
SELECT
|
||||||
|
p.id_erp,
|
||||||
|
p.codigo,
|
||||||
|
p.descricao,
|
||||||
|
p.unidade,
|
||||||
|
p.vl_preco1::text,
|
||||||
|
p.cod_grupo,
|
||||||
|
p.grupo,
|
||||||
|
p.cod_subgrupo,
|
||||||
|
p.subgrupo,
|
||||||
|
p.marca,
|
||||||
|
p.ativo,
|
||||||
|
e.qtd_estoque::text,
|
||||||
|
p.lista_parauta,
|
||||||
|
p.referencia,
|
||||||
|
p.descricao_detalhada,
|
||||||
|
p.vl_preco2::text,
|
||||||
|
p.vl_preco3::text,
|
||||||
|
p.aliq_ipi::text,
|
||||||
|
p.peso_liquido::text,
|
||||||
|
p.qtd_volume::text,
|
||||||
|
p.lote_mul_venda,
|
||||||
|
p.preco_com_ipi::text,
|
||||||
|
p.preco_promocional::text
|
||||||
|
FROM vw_produtos p
|
||||||
|
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
|
||||||
|
WHERE p.id_erp = ${idErp} AND p.ativo = 1
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const p = rows[0];
|
||||||
if (!p) return null;
|
if (!p) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
idErp: Number(p.id_erp),
|
||||||
code: p.code,
|
codigo: p.codigo,
|
||||||
name: p.name,
|
descricao: p.descricao,
|
||||||
description: p.description,
|
unidade: p.unidade,
|
||||||
category: p.category,
|
vlPreco1: p.vl_preco1,
|
||||||
unitPrice: decimalToString(p.unitPrice) ?? '0',
|
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
|
||||||
stock: decimalToString(p.stock),
|
grupo: p.grupo,
|
||||||
active: p.active,
|
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
|
||||||
erpCode: p.erpCode,
|
subgrupo: p.subgrupo,
|
||||||
syncedAt: p.syncedAt?.toISOString() ?? null,
|
marca: p.marca,
|
||||||
createdAt: p.createdAt.toISOString(),
|
ativo: Number(p.ativo),
|
||||||
updatedAt: p.updatedAt.toISOString(),
|
qtdEstoque: p.qtd_estoque,
|
||||||
|
listaParauta: p.lista_parauta !== null ? Number(p.lista_parauta) : null,
|
||||||
|
referencia: p.referencia,
|
||||||
|
descricaoDetalhada: p.descricao_detalhada,
|
||||||
|
vlPreco2: p.vl_preco2,
|
||||||
|
vlPreco3: p.vl_preco3,
|
||||||
|
aliqIpi: p.aliq_ipi,
|
||||||
|
pesoLiquido: p.peso_liquido,
|
||||||
|
qtdVolume: p.qtd_volume,
|
||||||
|
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
|
||||||
|
precoComIpi: p.preco_com_ipi,
|
||||||
|
precoPromocional: p.preco_promocional,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async sync(req: ProductSyncRequest): Promise<ProductSyncResponse> {
|
|
||||||
const prisma = this.cls.get('prisma');
|
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
|
||||||
|
|
||||||
const syncedAt = new Date();
|
|
||||||
let upserted = 0;
|
|
||||||
|
|
||||||
for (const item of req.items) {
|
|
||||||
await prisma.product.upsert({
|
|
||||||
where: { code: item.code },
|
|
||||||
create: {
|
|
||||||
code: item.code,
|
|
||||||
name: item.name,
|
|
||||||
description: item.description ?? null,
|
|
||||||
category: item.category ?? 'geral',
|
|
||||||
unitPrice: item.unitPrice,
|
|
||||||
stock: item.stock ?? null,
|
|
||||||
active: item.active ?? true,
|
|
||||||
erpCode: item.erpCode ?? null,
|
|
||||||
syncedAt,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
name: item.name,
|
|
||||||
description: item.description ?? null,
|
|
||||||
category: item.category ?? 'geral',
|
|
||||||
unitPrice: item.unitPrice,
|
|
||||||
stock: item.stock ?? null,
|
|
||||||
active: item.active ?? true,
|
|
||||||
erpCode: item.erpCode ?? null,
|
|
||||||
syncedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
upserted++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { upserted, syncedAt: syncedAt.toISOString() };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,27 @@
|
|||||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
ClientListQuerySchema,
|
ClientListQuerySchema,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
type ClientListQuery,
|
type ClientListQuery,
|
||||||
type ClientListResponse,
|
type ClientListResponse,
|
||||||
type OrderSummary,
|
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
|
||||||
import { ClientsService } from './clients.service';
|
import { ClientsService } from './clients.service';
|
||||||
import { OrdersService } from '../orders/orders.service';
|
|
||||||
|
|
||||||
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
|
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
|
||||||
|
|
||||||
@Controller({ path: 'clients' })
|
@Controller({ path: 'clients' })
|
||||||
export class ClientsController {
|
export class ClientsController {
|
||||||
constructor(
|
constructor(private readonly clients: ClientsService) {}
|
||||||
private readonly clients: ClientsService,
|
|
||||||
private readonly orders: OrdersService,
|
|
||||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Query() query: ClientListQueryDto): Promise<ClientListResponse> {
|
list(@Query() query: ClientListQueryDto): Promise<ClientListResponse> {
|
||||||
// parse aplica defaults (page=1, limit=50) definidos no schema
|
|
||||||
const parsed = ClientListQuerySchema.parse(query) as ClientListQuery;
|
const parsed = ClientListQuerySchema.parse(query) as ClientListQuery;
|
||||||
return this.clients.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
return this.clients.list(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ClientDetail> {
|
findOne(@Param('id', ParseIntPipe) id: number): Promise<ClientDetail> {
|
||||||
return this.clients.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
return this.clients.findOne(id);
|
||||||
}
|
|
||||||
|
|
||||||
// Últimos 10 pedidos do cliente — exibidos na ficha (FR-2.4).
|
|
||||||
@Get(':id/orders')
|
|
||||||
clientOrders(@Param('id', ParseUUIDPipe) id: string): Promise<OrderSummary[]> {
|
|
||||||
return this.orders.listByClient(
|
|
||||||
id,
|
|
||||||
this.cls.get('userId') ?? '',
|
|
||||||
this.cls.get('role') ?? 'rep',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ClientsController } from './clients.controller';
|
import { ClientsController } from './clients.controller';
|
||||||
import { ClientsService } from './clients.service';
|
import { ClientsService } from './clients.service';
|
||||||
import { OrdersModule } from '../orders/orders.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OrdersModule],
|
|
||||||
controllers: [ClientsController],
|
controllers: [ClientsController],
|
||||||
providers: [ClientsService],
|
providers: [ClientsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import type {
|
import type {
|
||||||
ClientDetail,
|
ClientDetail,
|
||||||
ClientListQuery,
|
ClientListQuery,
|
||||||
@@ -10,123 +9,187 @@ import type {
|
|||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
// Thresholds de atividade (FR-2.3). Configuráveis por workspace futuramente.
|
// Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
|
||||||
const ALERT_DAYS = 30;
|
const ALERT_DAYS = 30;
|
||||||
const INACTIVE_DAYS = 60;
|
const INACTIVE_DAYS = 60;
|
||||||
|
|
||||||
function activityStatus(lastOrderAt: Date | null): ActivityStatus {
|
function activityStatus(dtUltimaCompra: Date | null): ActivityStatus {
|
||||||
if (!lastOrderAt) return 'inactive';
|
if (!dtUltimaCompra) return 'inactive';
|
||||||
const days = Math.floor((Date.now() - lastOrderAt.getTime()) / 86_400_000);
|
const days = Math.floor((Date.now() - dtUltimaCompra.getTime()) / 86_400_000);
|
||||||
if (days >= INACTIVE_DAYS) return 'inactive';
|
if (days >= INACTIVE_DAYS) return 'inactive';
|
||||||
if (days >= ALERT_DAYS) return 'alert';
|
if (days >= ALERT_DAYS) return 'alert';
|
||||||
return 'active';
|
return 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
function decimalToString(v: Prisma.Decimal | null): string | null {
|
function escSql(s: string): string {
|
||||||
return v ? v.toString() : null;
|
return s.replace(/'/g, "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row bruta do $queryRawUnsafe
|
||||||
|
interface ClientRow {
|
||||||
|
id_cliente: number;
|
||||||
|
id_empresa: number;
|
||||||
|
nome: string;
|
||||||
|
razao: string | null;
|
||||||
|
cgcpf: string | null;
|
||||||
|
email: string | null;
|
||||||
|
telefone: string | null;
|
||||||
|
cod_vendedor: number;
|
||||||
|
limite_credito: string | null;
|
||||||
|
dt_ultima_compra: Date | null;
|
||||||
|
ativo: number;
|
||||||
|
pessoa: number | null;
|
||||||
|
inscricao_estadual: string | null;
|
||||||
|
endereco: string | null;
|
||||||
|
num_endereco: string | null;
|
||||||
|
bairro: string | null;
|
||||||
|
cep: string | null;
|
||||||
|
ddd: string | null;
|
||||||
|
obs: string | null;
|
||||||
|
cod_pauta: number | null;
|
||||||
|
dt_cadastro: string | null;
|
||||||
|
dt_atual: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClientsService {
|
export class ClientsService {
|
||||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
async list(query: ClientListQuery, userId: string, role: string): Promise<ClientListResponse> {
|
async list(query: ClientListQuery): Promise<ClientListResponse> {
|
||||||
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 role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
const { q, status, financialStatus, page, limit } = query;
|
const { q, status, page, limit } = query;
|
||||||
const skip = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
// Rep vê apenas sua carteira; supervisor/manager/admin vê tudo (FR-2.1).
|
// Rep vê apenas sua carteira (cod_vendedor = seu código)
|
||||||
const repFilter: Prisma.ClientWhereInput = role === 'rep' ? { repId: userId } : {};
|
const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : '';
|
||||||
|
const searchFilter = q
|
||||||
|
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
|
||||||
|
: '';
|
||||||
|
|
||||||
const searchFilter: Prisma.ClientWhereInput = q
|
const rows = await prisma.$queryRawUnsafe<ClientRow[]>(`
|
||||||
? {
|
SELECT
|
||||||
OR: [
|
c.id_cliente,
|
||||||
{ name: { contains: q, mode: 'insensitive' } },
|
c.id_empresa,
|
||||||
{ tradeName: { contains: q, mode: 'insensitive' } },
|
c.nome,
|
||||||
{ taxId: { contains: q } },
|
c.razao,
|
||||||
],
|
c.cgcpf,
|
||||||
}
|
c.email,
|
||||||
: {};
|
c.telefone,
|
||||||
|
c.cod_vendedor,
|
||||||
|
c.limite_credito::text,
|
||||||
|
c.ativo,
|
||||||
|
c.pessoa,
|
||||||
|
c.inscricao_estadual,
|
||||||
|
c.endereco,
|
||||||
|
c.num_endereco,
|
||||||
|
c.bairro,
|
||||||
|
c.cep,
|
||||||
|
c.ddd,
|
||||||
|
c.obs,
|
||||||
|
c.cod_pauta,
|
||||||
|
c.dt_cadastro::text,
|
||||||
|
c.dt_atual::text,
|
||||||
|
MAX(p.dt_pedido) AS dt_ultima_compra
|
||||||
|
FROM vw_clientes c
|
||||||
|
LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa AND p.situa != 3
|
||||||
|
WHERE c.id_empresa = ${idEmpresa}
|
||||||
|
AND c.ativo = 1
|
||||||
|
${vendedorFilter}
|
||||||
|
${searchFilter}
|
||||||
|
GROUP BY
|
||||||
|
c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email,
|
||||||
|
c.telefone, c.cod_vendedor, c.limite_credito, c.ativo, c.pessoa,
|
||||||
|
c.inscricao_estadual, c.endereco, c.num_endereco, c.bairro, c.cep,
|
||||||
|
c.ddd, c.obs, c.cod_pauta, c.dt_cadastro, c.dt_atual
|
||||||
|
ORDER BY c.nome
|
||||||
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
|
`);
|
||||||
|
|
||||||
const financialFilter: Prisma.ClientWhereInput = financialStatus ? { financialStatus } : {};
|
const totalRows = await prisma.$queryRawUnsafe<[{ count: string }]>(`
|
||||||
|
SELECT COUNT(*)::text AS count
|
||||||
|
FROM vw_clientes c
|
||||||
|
WHERE c.id_empresa = ${idEmpresa}
|
||||||
|
AND c.ativo = 1
|
||||||
|
${vendedorFilter}
|
||||||
|
${searchFilter}
|
||||||
|
`);
|
||||||
|
const total = parseInt(totalRows[0]?.count ?? '0', 10);
|
||||||
|
|
||||||
const where: Prisma.ClientWhereInput = {
|
let mapped: ClientSummary[] = rows.map((r) => ({
|
||||||
deletedAt: null,
|
idCliente: Number(r.id_cliente),
|
||||||
...repFilter,
|
idEmpresa: Number(r.id_empresa),
|
||||||
...searchFilter,
|
nome: r.nome,
|
||||||
...financialFilter,
|
razao: r.razao,
|
||||||
};
|
cgcpf: r.cgcpf,
|
||||||
|
email: r.email,
|
||||||
const [rows, total] = await Promise.all([
|
telefone: r.telefone,
|
||||||
prisma.client.findMany({
|
codVendedor: Number(r.cod_vendedor),
|
||||||
where,
|
limiteCreditoStr: r.limite_credito,
|
||||||
select: {
|
activityStatus: activityStatus(r.dt_ultima_compra),
|
||||||
id: true,
|
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
|
||||||
name: true,
|
|
||||||
tradeName: true,
|
|
||||||
taxId: true,
|
|
||||||
financialStatus: true,
|
|
||||||
lastOrderAt: true,
|
|
||||||
lastOrderValue: true,
|
|
||||||
openOrdersCount: true,
|
|
||||||
},
|
|
||||||
skip,
|
|
||||||
take: limit,
|
|
||||||
orderBy: { name: 'asc' },
|
|
||||||
}),
|
|
||||||
prisma.client.count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Filtra por activityStatus depois do fetch (computed field — não persiste no DB).
|
|
||||||
const mapped: ClientSummary[] = rows.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
name: r.name,
|
|
||||||
tradeName: r.tradeName,
|
|
||||||
taxId: r.taxId,
|
|
||||||
financialStatus: r.financialStatus,
|
|
||||||
activityStatus: activityStatus(r.lastOrderAt),
|
|
||||||
lastOrderAt: r.lastOrderAt?.toISOString() ?? null,
|
|
||||||
lastOrderValue: decimalToString(r.lastOrderValue),
|
|
||||||
openOrdersCount: r.openOrdersCount,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const filtered = status ? mapped.filter((c) => c.activityStatus === status) : mapped;
|
if (status) mapped = mapped.filter((c) => c.activityStatus === status);
|
||||||
|
|
||||||
return { data: filtered, total, page, limit };
|
return { data: mapped, total, page, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string, userId: string, role: string): Promise<ClientDetail> {
|
async findOne(idCliente: number): Promise<ClientDetail> {
|
||||||
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 repFilter: Prisma.ClientWhereInput = role === 'rep' ? { repId: userId } : {};
|
const rows = await prisma.$queryRawUnsafe<ClientRow[]>(`
|
||||||
|
SELECT
|
||||||
|
c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email,
|
||||||
|
c.telefone, c.cod_vendedor, c.limite_credito::text,
|
||||||
|
c.ativo, c.pessoa, c.inscricao_estadual, c.endereco, c.num_endereco,
|
||||||
|
c.bairro, c.cep, c.ddd, c.obs, c.cod_pauta,
|
||||||
|
c.dt_cadastro::text, c.dt_atual::text,
|
||||||
|
MAX(p.dt_pedido) AS dt_ultima_compra
|
||||||
|
FROM vw_clientes c
|
||||||
|
LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa AND p.situa != 3
|
||||||
|
WHERE c.id_empresa = ${idEmpresa} AND c.id_cliente = ${idCliente}
|
||||||
|
GROUP BY c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email,
|
||||||
|
c.telefone, c.cod_vendedor, c.limite_credito, c.ativo, c.pessoa,
|
||||||
|
c.inscricao_estadual, c.endereco, c.num_endereco, c.bairro, c.cep,
|
||||||
|
c.ddd, c.obs, c.cod_pauta, c.dt_cadastro, c.dt_atual
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
const client = await prisma.client.findFirst({
|
const r = rows[0];
|
||||||
where: { id, deletedAt: null, ...repFilter },
|
if (!r) throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
|
||||||
});
|
|
||||||
|
|
||||||
if (!client) throw new NotFoundException(`Cliente ${id} não encontrado`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: client.id,
|
idCliente: Number(r.id_cliente),
|
||||||
name: client.name,
|
idEmpresa: Number(r.id_empresa),
|
||||||
tradeName: client.tradeName,
|
nome: r.nome,
|
||||||
taxId: client.taxId,
|
razao: r.razao,
|
||||||
email: client.email,
|
cgcpf: r.cgcpf,
|
||||||
phone: client.phone,
|
email: r.email,
|
||||||
address: client.address as ClientDetail['address'],
|
telefone: r.telefone,
|
||||||
financialStatus: client.financialStatus,
|
codVendedor: Number(r.cod_vendedor),
|
||||||
activityStatus: activityStatus(client.lastOrderAt),
|
limiteCreditoStr: r.limite_credito,
|
||||||
creditLimit: decimalToString(client.creditLimit),
|
activityStatus: activityStatus(r.dt_ultima_compra),
|
||||||
lastOrderAt: client.lastOrderAt?.toISOString() ?? null,
|
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
|
||||||
lastOrderValue: decimalToString(client.lastOrderValue),
|
ativo: Number(r.ativo),
|
||||||
openOrdersCount: client.openOrdersCount,
|
pessoa: r.pessoa !== null ? Number(r.pessoa) : null,
|
||||||
erpCode: client.erpCode,
|
inscricaoEstadual: r.inscricao_estadual,
|
||||||
syncedAt: client.syncedAt?.toISOString() ?? null,
|
endereco: r.endereco,
|
||||||
createdAt: client.createdAt.toISOString(),
|
numEndereco: r.num_endereco,
|
||||||
updatedAt: client.updatedAt.toISOString(),
|
bairro: r.bairro,
|
||||||
|
cep: r.cep,
|
||||||
|
ddd: r.ddd,
|
||||||
|
obs: r.obs,
|
||||||
|
codPauta: r.cod_pauta !== null ? Number(r.cod_pauta) : null,
|
||||||
|
dtCadastro: r.dt_cadastro,
|
||||||
|
dtAtual: r.dt_atual,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import { OrderStatus } from '@prisma/client';
|
|
||||||
import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface';
|
import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
|
// Situa: 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
|
||||||
|
const SITUA_PENDENTE = 1;
|
||||||
|
const SITUA_APROVADO = 2;
|
||||||
|
const SITUA_FATURADO = 4;
|
||||||
|
const SITUA_CANCELADO = 3;
|
||||||
|
|
||||||
|
interface InativoRow {
|
||||||
|
id_cliente: number;
|
||||||
|
nome: string;
|
||||||
|
dt_ultima_compra: Date | null;
|
||||||
|
ultima_compra_valor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InativosPorRepRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
inativos_count: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
@@ -11,6 +28,9 @@ export class DashboardService {
|
|||||||
async repDashboard(userId: string): Promise<RepDashboard> {
|
async repDashboard(userId: string): Promise<RepDashboard> {
|
||||||
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 codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = now.getMonth() + 1;
|
const month = now.getMonth() + 1;
|
||||||
@@ -19,22 +39,23 @@ export class DashboardService {
|
|||||||
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
|
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
|
||||||
|
|
||||||
// Meta e taxas do mês
|
// Meta e taxas do mês
|
||||||
const target = await prisma.repTarget.findUnique({
|
const target = await prisma.metaRepresentante.findUnique({
|
||||||
where: { repId_year_month: { repId: userId, year, month } },
|
|
||||||
});
|
|
||||||
const targetAmount = target ? Number(target.targetAmount) : 0;
|
|
||||||
const commissionRate = target ? Number(target.commissionRate) : 3;
|
|
||||||
const flexRate = target ? Number(target.flexRate) : 1;
|
|
||||||
|
|
||||||
// Pedidos aprovados/faturados do mês (base do cálculo de meta e comissão)
|
|
||||||
const approvedThisMonth = await prisma.order.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
repId: userId,
|
codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano: year, mes: month },
|
||||||
deletedAt: null,
|
},
|
||||||
status: { in: [OrderStatus.approved, OrderStatus.invoiced] },
|
});
|
||||||
issuedAt: { gte: monthStart, lte: monthEnd },
|
const targetAmount = target ? Number(target.metaValor) : 0;
|
||||||
|
const commissionRate = target ? Number(target.taxaComissao) : 3;
|
||||||
|
const flexRate = target ? Number(target.taxaFlex) : 1;
|
||||||
|
|
||||||
|
// Pedidos aprovados/faturados do mês
|
||||||
|
const approvedThisMonth = await prisma.pedido.findMany({
|
||||||
|
where: {
|
||||||
|
codVendedor,
|
||||||
|
idEmpresa,
|
||||||
|
situa: { in: [SITUA_APROVADO, SITUA_FATURADO] },
|
||||||
|
dtPedido: { gte: monthStart, lte: monthEnd },
|
||||||
},
|
},
|
||||||
include: { client: { select: { name: true } } },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const atingido = approvedThisMonth.reduce((s, o) => s + Number(o.total), 0);
|
const atingido = approvedThisMonth.reduce((s, o) => s + Number(o.total), 0);
|
||||||
@@ -45,41 +66,47 @@ export class DashboardService {
|
|||||||
const flex =
|
const flex =
|
||||||
targetAmount > 0 && atingido >= targetAmount ? Math.round(atingido * flexRate) / 100 : 0;
|
targetAmount > 0 && atingido >= targetAmount ? Math.round(atingido * flexRate) / 100 : 0;
|
||||||
|
|
||||||
// Contagem total de pedidos no mês (todos status exceto cancelado)
|
// Contagem total de pedidos no mês (exceto cancelado)
|
||||||
const pedidosMes = await prisma.order.count({
|
const pedidosMes = await prisma.pedido.count({
|
||||||
where: {
|
where: {
|
||||||
repId: userId,
|
codVendedor,
|
||||||
deletedAt: null,
|
idEmpresa,
|
||||||
status: { not: OrderStatus.cancelled },
|
situa: { not: SITUA_CANCELADO },
|
||||||
issuedAt: { gte: monthStart, lte: monthEnd },
|
dtPedido: { gte: monthStart, lte: monthEnd },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pedidos recentes — últimos 7 dias
|
// Pedidos recentes — últimos 7 dias
|
||||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
const recentOrders = await prisma.order.findMany({
|
const recentOrders = await prisma.pedido.findMany({
|
||||||
where: {
|
where: {
|
||||||
repId: userId,
|
codVendedor,
|
||||||
deletedAt: null,
|
idEmpresa,
|
||||||
status: { not: OrderStatus.cancelled },
|
situa: { not: SITUA_CANCELADO },
|
||||||
issuedAt: { gte: sevenDaysAgo },
|
dtPedido: { gte: sevenDaysAgo },
|
||||||
},
|
},
|
||||||
include: { client: { select: { name: true } } },
|
orderBy: { dtPedido: 'desc' },
|
||||||
orderBy: { issuedAt: 'desc' },
|
|
||||||
take: 10,
|
take: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clientes inativos — sem compra há > 30 dias (ou nunca compraram)
|
// Clientes inativos — sem compra há >30 dias (via view + pedidos SAR)
|
||||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
const inactiveClients = await prisma.client.findMany({
|
const inactiveClients = await prisma.$queryRawUnsafe<InativoRow[]>(`
|
||||||
where: {
|
SELECT
|
||||||
repId: userId,
|
c.id_cliente,
|
||||||
deletedAt: null,
|
c.nome,
|
||||||
OR: [{ lastOrderAt: null }, { lastOrderAt: { lt: thirtyDaysAgo } }],
|
MAX(p.dt_pedido) AS dt_ultima_compra,
|
||||||
},
|
MAX(p.total)::text AS ultima_compra_valor
|
||||||
orderBy: { lastOrderAt: { sort: 'asc', nulls: 'first' } },
|
FROM vw_clientes c
|
||||||
take: 10,
|
LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa AND p.situa != ${SITUA_CANCELADO}
|
||||||
});
|
WHERE c.id_empresa = ${idEmpresa}
|
||||||
|
AND c.cod_vendedor = ${codVendedor}
|
||||||
|
AND c.ativo = 1
|
||||||
|
GROUP BY c.id_cliente, c.nome
|
||||||
|
HAVING MAX(p.dt_pedido) IS NULL OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString()}'
|
||||||
|
ORDER BY dt_ultima_compra ASC NULLS FIRST
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: { atingido, total: targetAmount, pct, falta },
|
meta: { atingido, total: targetAmount, pct, falta },
|
||||||
@@ -87,26 +114,23 @@ export class DashboardService {
|
|||||||
pedidosMes,
|
pedidosMes,
|
||||||
pedidosRecentes: recentOrders.map((o) => ({
|
pedidosRecentes: recentOrders.map((o) => ({
|
||||||
id: o.id,
|
id: o.id,
|
||||||
number: o.number,
|
numPedSar: o.numPedSar,
|
||||||
clientId: o.clientId,
|
idCliente: o.idCliente,
|
||||||
clientName: o.client.name,
|
codVendedor: o.codVendedor,
|
||||||
repId: o.repId,
|
situa: o.situa,
|
||||||
status: o.status,
|
dtPedido: o.dtPedido.toISOString(),
|
||||||
subtotal: String(o.subtotal),
|
|
||||||
total: String(o.total),
|
total: String(o.total),
|
||||||
discountPct: String(o.discountPct),
|
descontoPerc: String(o.descontoPerc),
|
||||||
issuedAt: o.issuedAt.toISOString(),
|
obs: o.obs,
|
||||||
approvedAt: o.approvedAt?.toISOString() ?? null,
|
createdAt: o.createdAt.toISOString(),
|
||||||
invoicedAt: o.invoicedAt?.toISOString() ?? null,
|
|
||||||
cancelledAt: o.cancelledAt?.toISOString() ?? null,
|
|
||||||
})),
|
})),
|
||||||
clientesInativos: inactiveClients.map((c) => ({
|
clientesInativos: inactiveClients.map((c) => ({
|
||||||
id: c.id,
|
idCliente: Number(c.id_cliente),
|
||||||
name: c.name,
|
nome: c.nome,
|
||||||
diasSemCompra: c.lastOrderAt
|
diasSemCompra: c.dt_ultima_compra
|
||||||
? Math.floor((now.getTime() - c.lastOrderAt.getTime()) / 86_400_000)
|
? Math.floor((now.getTime() - c.dt_ultima_compra.getTime()) / 86_400_000)
|
||||||
: 999,
|
: 999,
|
||||||
ultimaCompraValor: c.lastOrderValue !== null ? String(c.lastOrderValue) : null,
|
ultimaCompraValor: c.ultima_compra_valor,
|
||||||
})),
|
})),
|
||||||
syncedAt: now.toISOString(),
|
syncedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
@@ -115,68 +139,67 @@ export class DashboardService {
|
|||||||
async supervisorDashboard(): Promise<SupervisorDashboard> {
|
async supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||||
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 now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Fila de aprovações — mais antigos primeiro
|
// Fila de aprovações — mais antigos primeiro
|
||||||
const approvalQueue = await prisma.order.findMany({
|
const approvalQueue = await prisma.pedido.findMany({
|
||||||
where: { deletedAt: null, status: OrderStatus.pending_approval },
|
where: { idEmpresa, situa: SITUA_PENDENTE },
|
||||||
include: { client: { select: { name: true } } },
|
orderBy: { dtPedido: 'asc' },
|
||||||
orderBy: { issuedAt: 'asc' },
|
|
||||||
take: 50,
|
take: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pedidos do dia (hoje, meia-noite até agora)
|
// Pedidos do dia
|
||||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
const todayOrders = await prisma.order.findMany({
|
const todayOrders = await prisma.pedido.findMany({
|
||||||
where: {
|
where: {
|
||||||
deletedAt: null,
|
idEmpresa,
|
||||||
status: { not: OrderStatus.cancelled },
|
situa: { not: SITUA_CANCELADO },
|
||||||
issuedAt: { gte: todayStart },
|
dtPedido: { gte: todayStart },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mesmo dia da semana passada (comparativo)
|
// Mesmo dia da semana passada
|
||||||
const lastWeekStart = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const lastWeekStart = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
const lastWeekEnd = new Date(lastWeekStart.getTime() + 24 * 60 * 60 * 1000 - 1);
|
const lastWeekEnd = new Date(lastWeekStart.getTime() + 24 * 60 * 60 * 1000 - 1);
|
||||||
const lastWeekOrders = await prisma.order.findMany({
|
const lastWeekOrders = await prisma.pedido.findMany({
|
||||||
where: {
|
where: {
|
||||||
deletedAt: null,
|
idEmpresa,
|
||||||
status: { not: OrderStatus.cancelled },
|
situa: { not: SITUA_CANCELADO },
|
||||||
issuedAt: { gte: lastWeekStart, lte: lastWeekEnd },
|
dtPedido: { gte: lastWeekStart, lte: lastWeekEnd },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inativos por rep — top 3 reps com mais clientes inativos (>30 dias)
|
// Inativos por rep — top 3
|
||||||
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.client.groupBy({
|
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
|
||||||
by: ['repId'],
|
SELECT
|
||||||
where: {
|
c.cod_vendedor,
|
||||||
deletedAt: null,
|
COUNT(c.id_cliente)::text AS inativos_count
|
||||||
OR: [{ lastOrderAt: null }, { lastOrderAt: { lt: thirtyDaysAgo } }],
|
FROM vw_clientes c
|
||||||
},
|
LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa AND p.situa != ${SITUA_CANCELADO}
|
||||||
_count: { id: true },
|
WHERE c.id_empresa = ${idEmpresa} AND c.ativo = 1
|
||||||
orderBy: { _count: { id: 'desc' } },
|
GROUP BY c.cod_vendedor, c.id_cliente
|
||||||
take: 3,
|
HAVING MAX(p.dt_pedido) IS NULL OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString()}'
|
||||||
});
|
ORDER BY inativos_count DESC
|
||||||
|
LIMIT 3
|
||||||
|
`);
|
||||||
|
|
||||||
const mapOrder = (o: (typeof approvalQueue)[number]) => ({
|
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
|
||||||
id: o.id,
|
id: o.id,
|
||||||
number: o.number,
|
numPedSar: o.numPedSar,
|
||||||
clientId: o.clientId,
|
idCliente: o.idCliente,
|
||||||
clientName: o.client.name,
|
codVendedor: o.codVendedor,
|
||||||
repId: o.repId,
|
situa: o.situa,
|
||||||
status: o.status,
|
dtPedido: o.dtPedido.toISOString(),
|
||||||
subtotal: String(o.subtotal),
|
|
||||||
total: String(o.total),
|
total: String(o.total),
|
||||||
discountPct: String(o.discountPct),
|
descontoPerc: String(o.descontoPerc),
|
||||||
issuedAt: o.issuedAt.toISOString(),
|
obs: o.obs,
|
||||||
approvedAt: o.approvedAt?.toISOString() ?? null,
|
createdAt: o.createdAt.toISOString(),
|
||||||
invoicedAt: o.invoicedAt?.toISOString() ?? null,
|
|
||||||
cancelledAt: o.cancelledAt?.toISOString() ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
approvalQueue: approvalQueue.map(mapOrder),
|
approvalQueue: approvalQueue.map(mapPedido),
|
||||||
pedidosDia: {
|
pedidosDia: {
|
||||||
count: todayOrders.length,
|
count: todayOrders.length,
|
||||||
total: todayOrders.reduce((s, o) => s + Number(o.total), 0),
|
total: todayOrders.reduce((s, o) => s + Number(o.total), 0),
|
||||||
@@ -184,8 +207,8 @@ export class DashboardService {
|
|||||||
totalSemanaAnterior: lastWeekOrders.reduce((s, o) => s + Number(o.total), 0),
|
totalSemanaAnterior: lastWeekOrders.reduce((s, o) => s + Number(o.total), 0),
|
||||||
},
|
},
|
||||||
inativosPorRep: inativosPorRep.map((r) => ({
|
inativosPorRep: inativosPorRep.map((r) => ({
|
||||||
repId: r.repId,
|
codVendedor: Number(r.cod_vendedor),
|
||||||
inativosCount: r._count.id,
|
inativosCount: parseInt(r.inativos_count, 10),
|
||||||
})),
|
})),
|
||||||
syncedAt: now.toISOString(),
|
syncedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import { OrderStatus } from '@prisma/client';
|
|
||||||
import type { SubscribePayload, PendingCountResponse } from '@sar/api-interface';
|
import type { SubscribePayload, PendingCountResponse } from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
import { PushService, type PushPayload } from './push.service';
|
import { PushService, type PushPayload } from './push.service';
|
||||||
|
|
||||||
|
// Situa: 1=Pendente Aprovação
|
||||||
|
const SITUA_PENDENTE = 1;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -15,12 +17,21 @@ export class NotificationsService {
|
|||||||
async subscribe(userId: string, role: string, dto: SubscribePayload): Promise<void> {
|
async subscribe(userId: string, role: string, dto: SubscribePayload): Promise<void> {
|
||||||
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 codVendedor = userId ? parseInt(userId, 10) : null;
|
||||||
|
|
||||||
await prisma.pushSubscription.upsert({
|
await prisma.pushSubscription.upsert({
|
||||||
where: { endpoint: dto.endpoint },
|
where: { endpoint: dto.endpoint },
|
||||||
update: { userId, role, p256dh: dto.keys.p256dh, auth: dto.keys.auth },
|
update: {
|
||||||
|
codVendedor,
|
||||||
|
idEmpresa,
|
||||||
|
role,
|
||||||
|
p256dh: dto.keys.p256dh,
|
||||||
|
auth: dto.keys.auth,
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
userId,
|
codVendedor,
|
||||||
|
idEmpresa,
|
||||||
role,
|
role,
|
||||||
endpoint: dto.endpoint,
|
endpoint: dto.endpoint,
|
||||||
p256dh: dto.keys.p256dh,
|
p256dh: dto.keys.p256dh,
|
||||||
@@ -39,10 +50,11 @@ export class NotificationsService {
|
|||||||
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
|
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
|
||||||
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');
|
||||||
|
|
||||||
if (role === 'supervisor' || role === 'manager' || role === 'admin') {
|
if (role === 'supervisor' || role === 'manager' || role === 'admin') {
|
||||||
const count = await prisma.order.count({
|
const count = await prisma.pedido.count({
|
||||||
where: { status: OrderStatus.pending_approval, deletedAt: null },
|
where: { situa: SITUA_PENDENTE, idEmpresa },
|
||||||
});
|
});
|
||||||
return { count };
|
return { count };
|
||||||
}
|
}
|
||||||
@@ -50,24 +62,32 @@ export class NotificationsService {
|
|||||||
return { count: 0 };
|
return { count: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Envia push para todos os supervisores/managers/admin do workspace.
|
// Envia push para todos os supervisores/managers/admin da empresa.
|
||||||
async notifySupervisors(payload: PushPayload): Promise<void> {
|
async notifySupervisors(payload: PushPayload): Promise<void> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) return;
|
if (!prisma) return;
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
const subs = await prisma.pushSubscription.findMany({
|
const subs = await prisma.pushSubscription.findMany({
|
||||||
where: { role: { in: ['supervisor', 'manager', 'admin'] } },
|
where: {
|
||||||
|
idEmpresa,
|
||||||
|
role: { in: ['supervisor', 'manager', 'admin'] },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
|
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Envia push para um userId específico (todos os dispositivos registrados).
|
// Envia push para um codVendedor específico (todos os dispositivos registrados).
|
||||||
async notifyUser(userId: string, payload: PushPayload): Promise<void> {
|
async notifyUser(userId: string, payload: PushPayload): Promise<void> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) return;
|
if (!prisma) return;
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
const subs = await prisma.pushSubscription.findMany({ where: { userId } });
|
const subs = await prisma.pushSubscription.findMany({
|
||||||
|
where: { idEmpresa, codVendedor },
|
||||||
|
});
|
||||||
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
|
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,24 +13,24 @@ import {
|
|||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
ApproveOrderSchema,
|
AprovarPedidoSchema,
|
||||||
CreateOrderSchema,
|
CreatePedidoSchema,
|
||||||
OrderListQuerySchema,
|
PedidoListQuerySchema,
|
||||||
RejectOrderSchema,
|
RecusarPedidoSchema,
|
||||||
type ApproveOrder,
|
type AprovarPedido,
|
||||||
type CreateOrder,
|
type CreatePedido,
|
||||||
type OrderDetail,
|
type PedidoDetail,
|
||||||
type OrderListQuery,
|
type PedidoListQuery,
|
||||||
type OrderListResponse,
|
type PedidoListResponse,
|
||||||
type RejectOrder,
|
type RecusarPedido,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
import { OrdersService } from './orders.service';
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
|
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
||||||
class CreateOrderDto extends createZodDto(CreateOrderSchema) {}
|
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
||||||
class ApproveOrderDto extends createZodDto(ApproveOrderSchema) {}
|
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
||||||
class RejectOrderDto extends createZodDto(RejectOrderSchema) {}
|
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
||||||
|
|
||||||
@Controller({ path: 'orders' })
|
@Controller({ path: 'orders' })
|
||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
@@ -40,42 +40,42 @@ export class OrdersController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Query() query: OrderListQueryDto): Promise<OrderListResponse> {
|
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
|
||||||
const parsed = OrderListQuerySchema.parse(query) as OrderListQuery;
|
const parsed = PedidoListQuerySchema.parse(query) as PedidoListQuery;
|
||||||
return this.orders.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
return this.orders.list(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(201)
|
@HttpCode(201)
|
||||||
create(@Body() body: CreateOrderDto): Promise<OrderDetail> {
|
create(@Body() body: CreatePedidoDto): Promise<PedidoDetail> {
|
||||||
const parsed = CreateOrderSchema.parse(body) as CreateOrder;
|
const parsed = CreatePedidoSchema.parse(body) as CreatePedido;
|
||||||
return this.orders.create(parsed, this.cls.get('userId') ?? '');
|
return this.orders.create(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/approve')
|
@Patch(':id/approve')
|
||||||
approve(
|
approve(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() body: ApproveOrderDto,
|
@Body() body: AprovarPedidoDto,
|
||||||
): Promise<OrderDetail> {
|
): Promise<PedidoDetail> {
|
||||||
const role = this.cls.get('role') ?? 'rep';
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
|
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
|
||||||
const parsed = ApproveOrderSchema.parse(body) as ApproveOrder;
|
const parsed = AprovarPedidoSchema.parse(body) as AprovarPedido;
|
||||||
return this.orders.approve(id, this.cls.get('userId') ?? '', parsed);
|
return this.orders.approve(id, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/reject')
|
@Patch(':id/reject')
|
||||||
reject(
|
reject(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() body: RejectOrderDto,
|
@Body() body: RecusarPedidoDto,
|
||||||
): Promise<OrderDetail> {
|
): Promise<PedidoDetail> {
|
||||||
const role = this.cls.get('role') ?? 'rep';
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
|
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
|
||||||
const parsed = RejectOrderSchema.parse(body) as RejectOrder;
|
const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido;
|
||||||
return this.orders.reject(id, this.cls.get('userId') ?? '', parsed);
|
return this.orders.reject(id, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderDetail> {
|
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||||
return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
return this.orders.findOne(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import { OrderStatus, Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import type {
|
import type {
|
||||||
ApproveOrder,
|
AprovarPedido,
|
||||||
CreateOrder,
|
CreatePedido,
|
||||||
OrderDetail,
|
PedidoDetail,
|
||||||
OrderListQuery,
|
PedidoListQuery,
|
||||||
OrderListResponse,
|
PedidoListResponse,
|
||||||
OrderSummary,
|
PedidoSummary,
|
||||||
RejectOrder,
|
RecusarPedido,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
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: 1=Pendente Aprovação, 2=Aprovado, 3=Cancelado, 4=Faturado
|
||||||
|
const SITUA_PENDENTE = 1;
|
||||||
|
const SITUA_APROVADO = 2;
|
||||||
|
const SITUA_CANCELADO = 3;
|
||||||
|
|
||||||
function decimalToString(v: Prisma.Decimal | null | undefined): string {
|
function decimalToString(v: Prisma.Decimal | null | undefined): string {
|
||||||
return v ? v.toString() : '0';
|
return v ? v.toString() : '0';
|
||||||
}
|
}
|
||||||
@@ -24,24 +29,29 @@ export class OrdersService {
|
|||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(query: OrderListQuery, userId: string, role: string): Promise<OrderListResponse> {
|
async list(query: PedidoListQuery): Promise<PedidoListResponse> {
|
||||||
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 role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
const { clientId, status, number, from, to, page, limit } = query;
|
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
|
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||||
|
|
||||||
const where: Prisma.OrderWhereInput = {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
deletedAt: null,
|
const where: any = {
|
||||||
|
idEmpresa,
|
||||||
...repFilter,
|
...repFilter,
|
||||||
...(clientId ? { clientId } : {}),
|
...(idCliente != null ? { idCliente } : {}),
|
||||||
...(status ? { status } : {}),
|
...(situa != null ? { situa } : {}),
|
||||||
...(number ? { number: { contains: number, mode: 'insensitive' } } : {}),
|
...(numPedSar ? { numPedSar: { contains: numPedSar } } : {}),
|
||||||
...(from || to
|
...(from || to
|
||||||
? {
|
? {
|
||||||
issuedAt: {
|
dtPedido: {
|
||||||
...(from ? { gte: new Date(from) } : {}),
|
...(from ? { gte: new Date(from) } : {}),
|
||||||
...(to ? { lte: new Date(to) } : {}),
|
...(to ? { lte: new Date(to) } : {}),
|
||||||
},
|
},
|
||||||
@@ -50,349 +60,267 @@ export class OrdersService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [rows, total] = await Promise.all([
|
const [rows, total] = await Promise.all([
|
||||||
prisma.order.findMany({
|
prisma.pedido.findMany({
|
||||||
where,
|
where,
|
||||||
include: { client: { select: { name: true } } },
|
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { issuedAt: 'desc' },
|
orderBy: { dtPedido: 'desc' },
|
||||||
}),
|
}),
|
||||||
prisma.order.count({ where }),
|
prisma.pedido.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data: OrderSummary[] = rows.map((o) => ({
|
const data: PedidoSummary[] = rows.map((o) => ({
|
||||||
id: o.id,
|
id: o.id,
|
||||||
number: o.number,
|
numPedSar: o.numPedSar,
|
||||||
clientId: o.clientId,
|
idCliente: o.idCliente,
|
||||||
clientName: o.client.name,
|
codVendedor: o.codVendedor,
|
||||||
repId: o.repId,
|
situa: o.situa,
|
||||||
status: o.status,
|
dtPedido: o.dtPedido.toISOString(),
|
||||||
discountPct: decimalToString(o.discountPct),
|
|
||||||
subtotal: decimalToString(o.subtotal),
|
|
||||||
total: decimalToString(o.total),
|
total: decimalToString(o.total),
|
||||||
issuedAt: o.issuedAt.toISOString(),
|
descontoPerc: decimalToString(o.descontoPerc),
|
||||||
approvedAt: o.approvedAt?.toISOString() ?? null,
|
obs: o.obs,
|
||||||
invoicedAt: o.invoicedAt?.toISOString() ?? null,
|
createdAt: o.createdAt.toISOString(),
|
||||||
cancelledAt: o.cancelledAt?.toISOString() ?? null,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { data, total, page, limit };
|
return { data, total, page, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string, userId: string, role: string): Promise<OrderDetail> {
|
async findOne(id: string): Promise<PedidoDetail> {
|
||||||
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 role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
|
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||||
|
|
||||||
const o = await prisma.order.findFirst({
|
const o = await prisma.pedido.findFirst({
|
||||||
where: { id, deletedAt: null, ...repFilter },
|
where: { id, idEmpresa, ...repFilter },
|
||||||
include: {
|
include: {
|
||||||
client: { select: { name: true } },
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
items: true,
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
history: { orderBy: { changedAt: 'asc' } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!o) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
if (!o) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||||
|
|
||||||
return {
|
return this.mapDetail(o);
|
||||||
id: o.id,
|
|
||||||
number: o.number,
|
|
||||||
clientId: o.clientId,
|
|
||||||
clientName: o.client.name,
|
|
||||||
repId: o.repId,
|
|
||||||
status: o.status,
|
|
||||||
discountPct: decimalToString(o.discountPct),
|
|
||||||
subtotal: decimalToString(o.subtotal),
|
|
||||||
total: decimalToString(o.total),
|
|
||||||
notes: o.notes,
|
|
||||||
approvedById: o.approvedById,
|
|
||||||
idempotencyKey: o.idempotencyKey,
|
|
||||||
issuedAt: o.issuedAt.toISOString(),
|
|
||||||
approvedAt: o.approvedAt?.toISOString() ?? null,
|
|
||||||
invoicedAt: o.invoicedAt?.toISOString() ?? null,
|
|
||||||
cancelledAt: o.cancelledAt?.toISOString() ?? null,
|
|
||||||
createdAt: o.createdAt.toISOString(),
|
|
||||||
updatedAt: o.updatedAt.toISOString(),
|
|
||||||
items: o.items.map((it) => ({
|
|
||||||
id: it.id,
|
|
||||||
productCode: it.productCode,
|
|
||||||
productName: it.productName,
|
|
||||||
quantity: decimalToString(it.quantity),
|
|
||||||
unitPrice: decimalToString(it.unitPrice),
|
|
||||||
discountPct: decimalToString(it.discountPct),
|
|
||||||
subtotal: decimalToString(it.subtotal),
|
|
||||||
})),
|
|
||||||
history: o.history.map((h) => ({
|
|
||||||
id: h.id,
|
|
||||||
fromStatus: h.fromStatus,
|
|
||||||
toStatus: h.toStatus,
|
|
||||||
changedById: h.changedById,
|
|
||||||
note: h.note,
|
|
||||||
changedAt: h.changedAt.toISOString(),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cria novo pedido. Valida alçada por linha de produto (OQ-2).
|
// Cria novo pedido. Valida alçada por codGrupo (codGrupo=0 = default).
|
||||||
// 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: CreateOrder, userId: string): Promise<OrderDetail> {
|
async create(dto: CreatePedido): Promise<PedidoDetail> {
|
||||||
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 userId = this.cls.get('userId') ?? '0';
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
// Idempotency-Key: retorna pedido existente sem re-processar
|
// Idempotency-Key: retorna pedido existente sem re-processar
|
||||||
if (dto.idempotencyKey) {
|
if (dto.idempotencyKey) {
|
||||||
const existing = await prisma.order.findUnique({
|
const existing = await prisma.pedido.findUnique({
|
||||||
where: { idempotencyKey: dto.idempotencyKey },
|
where: { idempotencyKey: dto.idempotencyKey },
|
||||||
include: {
|
include: {
|
||||||
client: { select: { name: true } },
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
items: true,
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
history: { orderBy: { changedAt: 'asc' } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (existing) return this.mapDetail(existing);
|
if (existing) return this.mapDetail(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verifica que o cliente existe e pertence ao rep
|
// Resolve alçadas: (codVendedor, idEmpresa, codGrupo=0) = default
|
||||||
const client = await prisma.client.findFirst({
|
const limitRows = await prisma.alcadaDesconto.findMany({
|
||||||
where: { id: dto.clientId, repId: userId, deletedAt: null },
|
where: { codVendedor, idEmpresa },
|
||||||
});
|
});
|
||||||
if (!client) throw new NotFoundException(`Cliente ${dto.clientId} não encontrado`);
|
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
||||||
|
const getLimit = (codGrupo: number) => limitMap.get(codGrupo) ?? limitMap.get(0) ?? 5;
|
||||||
|
|
||||||
// Resolve alçadas por categoria: (repId, category) → fallback (repId, '__default__') → 5%
|
// Alçada global (codGrupo=0)
|
||||||
const limitRows = await prisma.repDiscountLimit.findMany({ where: { repId: userId } });
|
const needsApproval = dto.descontoPerc > getLimit(0);
|
||||||
const limitMap = new Map(limitRows.map((r) => [r.category, Number(r.limit)]));
|
|
||||||
const getLimit = (category: string) =>
|
|
||||||
limitMap.get(category) ?? limitMap.get('__default__') ?? 5;
|
|
||||||
|
|
||||||
// Valida alçada item a item
|
// Calcula totais dos itens
|
||||||
let needsApproval = false;
|
const itemsData = dto.itens.map((it) => {
|
||||||
for (const item of dto.items) {
|
const descontoValor =
|
||||||
const lim = getLimit(item.productCategory ?? 'geral');
|
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
|
||||||
if (item.discountPct > lim) {
|
const total = Math.round(it.qtd * it.precoUnitario * (1 - it.descontoPerc / 100) * 100) / 100;
|
||||||
needsApproval = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Alçada global: compara desconto global do pedido com o default do rep
|
|
||||||
const globalLimit = getLimit('__default__');
|
|
||||||
if (dto.discountPct > globalLimit) needsApproval = true;
|
|
||||||
|
|
||||||
const status = needsApproval ? OrderStatus.pending_approval : OrderStatus.budget;
|
|
||||||
|
|
||||||
// Gera número sequencial: PED-NNNNN
|
|
||||||
const lastOrder = await prisma.order.findFirst({
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
select: { number: true },
|
|
||||||
});
|
|
||||||
const seq = lastOrder ? parseInt(lastOrder.number.replace('PED-', ''), 10) + 1 : 1;
|
|
||||||
const number = `PED-${String(seq).padStart(5, '0')}`;
|
|
||||||
|
|
||||||
// Calcula subtotais
|
|
||||||
const itemsData = dto.items.map((it) => {
|
|
||||||
const subtotal =
|
|
||||||
Math.round(it.quantity * it.unitPrice * (1 - it.discountPct / 100) * 100) / 100;
|
|
||||||
return {
|
return {
|
||||||
productCode: it.productCode,
|
ordem: it.ordem,
|
||||||
productName: it.productName,
|
idProduto: it.idProduto,
|
||||||
productCategory: it.productCategory ?? 'geral',
|
codProduto: it.codProduto ?? null,
|
||||||
quantity: it.quantity,
|
descProduto: it.descProduto,
|
||||||
unitPrice: it.unitPrice,
|
qtd: it.qtd,
|
||||||
discountPct: it.discountPct,
|
precoUnitario: it.precoUnitario,
|
||||||
subtotal,
|
descontoPerc: it.descontoPerc,
|
||||||
|
descontoValor,
|
||||||
|
total,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const itemsSubtotal = itemsData.reduce((acc, it) => acc + it.subtotal, 0);
|
|
||||||
const total = Math.round(itemsSubtotal * (1 - dto.discountPct / 100) * 100) / 100;
|
const totalProdutos = itemsData.reduce((acc, it) => acc + it.total, 0);
|
||||||
|
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
|
||||||
|
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
|
||||||
|
|
||||||
|
const situa = needsApproval ? SITUA_PENDENTE : SITUA_APROVADO;
|
||||||
|
|
||||||
|
// Gera número sequencial: SAR-NNNNN
|
||||||
|
const lastOrder = await prisma.pedido.findFirst({
|
||||||
|
where: { idEmpresa },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { numPedSar: true },
|
||||||
|
});
|
||||||
|
const seq = lastOrder ? parseInt(lastOrder.numPedSar.replace('SAR-', ''), 10) + 1 : 1;
|
||||||
|
const numPedSar = `SAR-${String(seq).padStart(5, '0')}`;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const order = await prisma.order.create({
|
const pedido = await prisma.pedido.create({
|
||||||
data: {
|
data: {
|
||||||
number,
|
idEmpresa,
|
||||||
clientId: dto.clientId,
|
numPedSar,
|
||||||
repId: userId,
|
idCliente: dto.idCliente,
|
||||||
status,
|
codVendedor,
|
||||||
discountPct: dto.discountPct,
|
situa,
|
||||||
subtotal: itemsSubtotal,
|
dtPedido: now,
|
||||||
|
idPauta: dto.idPauta ?? null,
|
||||||
|
codFormapag: dto.codFormapag ?? null,
|
||||||
|
totalProdutos,
|
||||||
total,
|
total,
|
||||||
notes: dto.notes ?? null,
|
descontoPerc: dto.descontoPerc,
|
||||||
|
descontoValor: descontoValorGlobal,
|
||||||
|
obs: dto.obs ?? null,
|
||||||
idempotencyKey: dto.idempotencyKey ?? null,
|
idempotencyKey: dto.idempotencyKey ?? null,
|
||||||
issuedAt: now,
|
itens: { create: itemsData },
|
||||||
items: { create: itemsData },
|
historico: {
|
||||||
history: {
|
|
||||||
create: [
|
create: [
|
||||||
{ fromStatus: null, toStatus: OrderStatus.budget, changedById: userId, changedAt: now },
|
{
|
||||||
|
situaAnterior: null,
|
||||||
|
situaNova: situa,
|
||||||
|
changedBy: codVendedor,
|
||||||
|
changedAt: now,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
client: { select: { name: true } },
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
items: true,
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
history: { orderBy: { changedAt: 'asc' } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (status === OrderStatus.pending_approval) {
|
if (situa === SITUA_PENDENTE) {
|
||||||
await prisma.orderStatusHistory.create({
|
|
||||||
data: {
|
|
||||||
orderId: order.id,
|
|
||||||
fromStatus: OrderStatus.budget,
|
|
||||||
toStatus: OrderStatus.pending_approval,
|
|
||||||
changedById: userId,
|
|
||||||
changedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atualiza desnorm do cliente
|
|
||||||
const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved];
|
|
||||||
const openCount = await prisma.order.count({
|
|
||||||
where: { clientId: dto.clientId, deletedAt: null, status: { in: openStatuses } },
|
|
||||||
});
|
|
||||||
await prisma.client.update({
|
|
||||||
where: { id: dto.clientId },
|
|
||||||
data: { lastOrderAt: now, lastOrderValue: total, openOrdersCount: openCount },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (status === OrderStatus.pending_approval) {
|
|
||||||
// FR-6.1: notifica supervisores que há pedido aguardando aprovação
|
|
||||||
void this.notifications.notifySupervisors({
|
void this.notifications.notifySupervisors({
|
||||||
title: 'Pedido aguardando aprovação',
|
title: 'Pedido aguardando aprovação',
|
||||||
body: `${order.client.name} — ${order.number} — R$ ${order.total.toFixed(2).replace('.', ',')}`,
|
body: `Pedido ${pedido.numPedSar} — R$ ${pedido.total.toFixed(2).replace('.', ',')}`,
|
||||||
url: `/pedidos/${order.id}`,
|
url: `/pedidos/${pedido.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = await prisma.order.findUniqueOrThrow({
|
|
||||||
where: { id: order.id },
|
|
||||||
include: {
|
|
||||||
client: { select: { name: true } },
|
|
||||||
items: true,
|
|
||||||
history: { orderBy: { changedAt: 'asc' } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return this.mapDetail(updated);
|
|
||||||
}
|
}
|
||||||
return this.mapDetail(order);
|
|
||||||
|
return this.mapDetail(pedido);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aprova pedido pending_approval. Supervisor pode ajustar discountPct global (FR-5.4).
|
// Aprova pedido pendente. Supervisor pode ajustar descontoPerc global.
|
||||||
async approve(id: string, userId: string, dto: ApproveOrder): Promise<OrderDetail> {
|
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
||||||
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 userId = this.cls.get('userId') ?? '0';
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
const order = await prisma.order.findFirst({ where: { id, deletedAt: null } });
|
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
|
||||||
if (!order) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||||
if (order.status !== OrderStatus.pending_approval)
|
if (pedido.situa !== SITUA_PENDENTE)
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Pedido não está aguardando aprovação (status: ${order.status})`,
|
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const newDiscountPct = dto.discountPct ?? Number(order.discountPct);
|
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
|
||||||
const newTotal = Math.round(Number(order.subtotal) * (1 - newDiscountPct / 100) * 100) / 100;
|
const newTotal =
|
||||||
|
Math.round(Number(pedido.totalProdutos) * (1 - newDescontoPerc / 100) * 100) / 100;
|
||||||
|
|
||||||
await prisma.order.update({
|
await prisma.pedido.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
status: OrderStatus.approved,
|
situa: SITUA_APROVADO,
|
||||||
discountPct: newDiscountPct,
|
descontoPerc: newDescontoPerc,
|
||||||
total: newTotal,
|
total: newTotal,
|
||||||
approvedById: userId,
|
aprovadoPor: codVendedor,
|
||||||
approvedAt: now,
|
aprovadoEm: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.orderStatusHistory.create({
|
await prisma.historicoPedido.create({
|
||||||
data: {
|
data: {
|
||||||
orderId: id,
|
idPedido: id,
|
||||||
fromStatus: OrderStatus.pending_approval,
|
situaAnterior: SITUA_PENDENTE,
|
||||||
toStatus: OrderStatus.approved,
|
situaNova: SITUA_APROVADO,
|
||||||
changedById: userId,
|
changedBy: codVendedor,
|
||||||
changedAt: now,
|
changedAt: now,
|
||||||
note: dto.note ?? null,
|
nota: dto.nota ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Atualiza desnorm openOrdersCount
|
const final = await prisma.pedido.findUniqueOrThrow({
|
||||||
const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved];
|
|
||||||
const openCount = await prisma.order.count({
|
|
||||||
where: { clientId: order.clientId, deletedAt: null, status: { in: openStatuses } },
|
|
||||||
});
|
|
||||||
await prisma.client.update({
|
|
||||||
where: { id: order.clientId },
|
|
||||||
data: { openOrdersCount: openCount },
|
|
||||||
});
|
|
||||||
|
|
||||||
const final = await prisma.order.findUniqueOrThrow({
|
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
client: { select: { name: true } },
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
items: true,
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
history: { orderBy: { changedAt: 'asc' } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// FR-6.1: notifica o rep que o pedido foi aprovado
|
void this.notifications.notifyUser(String(pedido.codVendedor), {
|
||||||
void this.notifications.notifyUser(order.repId, {
|
|
||||||
title: 'Pedido aprovado',
|
title: 'Pedido aprovado',
|
||||||
body: `${final.number} — ${final.client.name} aprovado${dto.discountPct !== undefined ? ` com ${newDiscountPct}% de desconto` : ''}`,
|
body: `${final.numPedSar} aprovado${dto.descontoPerc !== undefined ? ` com ${newDescontoPerc}% de desconto` : ''}`,
|
||||||
url: `/pedidos/${id}`,
|
url: `/pedidos/${id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.mapDetail(final);
|
return this.mapDetail(final);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recusa pedido — retorna ao status budget com motivo no histórico (FR-5.4).
|
// Recusa pedido — muda situa para 3 (Cancelado) com motivo.
|
||||||
async reject(id: string, userId: string, dto: RejectOrder): Promise<OrderDetail> {
|
async reject(id: string, dto: RecusarPedido): Promise<PedidoDetail> {
|
||||||
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 userId = this.cls.get('userId') ?? '0';
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
const order = await prisma.order.findFirst({ where: { id, deletedAt: null } });
|
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
|
||||||
if (!order) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||||
if (order.status !== OrderStatus.pending_approval)
|
if (pedido.situa !== SITUA_PENDENTE)
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Pedido não está aguardando aprovação (status: ${order.status})`,
|
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
await prisma.order.update({ where: { id }, data: { status: OrderStatus.budget } });
|
await prisma.pedido.update({
|
||||||
|
where: { id },
|
||||||
|
data: { situa: SITUA_CANCELADO, motivoRecusa: dto.motivo },
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.orderStatusHistory.create({
|
await prisma.historicoPedido.create({
|
||||||
data: {
|
data: {
|
||||||
orderId: id,
|
idPedido: id,
|
||||||
fromStatus: OrderStatus.pending_approval,
|
situaAnterior: SITUA_PENDENTE,
|
||||||
toStatus: OrderStatus.budget,
|
situaNova: SITUA_CANCELADO,
|
||||||
changedById: userId,
|
changedBy: codVendedor,
|
||||||
changedAt: now,
|
changedAt: now,
|
||||||
note: dto.reason,
|
nota: dto.motivo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Atualiza desnorm openOrdersCount
|
const final = await prisma.pedido.findUniqueOrThrow({
|
||||||
const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved];
|
|
||||||
const openCount = await prisma.order.count({
|
|
||||||
where: { clientId: order.clientId, deletedAt: null, status: { in: openStatuses } },
|
|
||||||
});
|
|
||||||
await prisma.client.update({
|
|
||||||
where: { id: order.clientId },
|
|
||||||
data: { openOrdersCount: openCount },
|
|
||||||
});
|
|
||||||
|
|
||||||
const final = await prisma.order.findUniqueOrThrow({
|
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
client: { select: { name: true } },
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
items: true,
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
history: { orderBy: { changedAt: 'asc' } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// FR-6.1: notifica o rep que o pedido foi recusado
|
void this.notifications.notifyUser(String(pedido.codVendedor), {
|
||||||
void this.notifications.notifyUser(order.repId, {
|
|
||||||
title: 'Pedido recusado',
|
title: 'Pedido recusado',
|
||||||
body: `${final.number} — ${final.client.name}: ${dto.reason}`,
|
body: `${final.numPedSar}: ${dto.motivo}`,
|
||||||
url: `/pedidos/${id}`,
|
url: `/pedidos/${id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -401,113 +329,89 @@ export class OrdersService {
|
|||||||
|
|
||||||
private mapDetail(o: {
|
private mapDetail(o: {
|
||||||
id: string;
|
id: string;
|
||||||
number: string;
|
numPedSar: string;
|
||||||
clientId: string;
|
idCliente: number;
|
||||||
client: { name: string };
|
codVendedor: number;
|
||||||
repId: string;
|
situa: number;
|
||||||
status: OrderStatus;
|
dtPedido: Date;
|
||||||
discountPct: Prisma.Decimal;
|
|
||||||
subtotal: Prisma.Decimal;
|
|
||||||
total: Prisma.Decimal;
|
total: Prisma.Decimal;
|
||||||
notes: string | null;
|
descontoPerc: Prisma.Decimal;
|
||||||
approvedById: string | null;
|
descontoValor: Prisma.Decimal;
|
||||||
|
totalProdutos: Prisma.Decimal;
|
||||||
|
totalIpi: Prisma.Decimal;
|
||||||
|
totalIcmsst: Prisma.Decimal;
|
||||||
|
acrescimo: Prisma.Decimal;
|
||||||
|
comissao: Prisma.Decimal;
|
||||||
|
pedFlex: Prisma.Decimal;
|
||||||
|
aprovadoPor: number | null;
|
||||||
|
aprovadoEm: Date | null;
|
||||||
|
motivoRecusa: string | null;
|
||||||
|
obs: string | null;
|
||||||
idempotencyKey: string | null;
|
idempotencyKey: string | null;
|
||||||
issuedAt: Date;
|
|
||||||
approvedAt: Date | null;
|
|
||||||
invoicedAt: Date | null;
|
|
||||||
cancelledAt: Date | null;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
items: {
|
itens: {
|
||||||
id: string;
|
id: string;
|
||||||
productCode: string;
|
idProduto: number;
|
||||||
productName: string;
|
codProduto: string | null;
|
||||||
quantity: Prisma.Decimal;
|
descProduto: string | null;
|
||||||
unitPrice: Prisma.Decimal;
|
ordem: number;
|
||||||
discountPct: Prisma.Decimal;
|
qtd: Prisma.Decimal;
|
||||||
subtotal: Prisma.Decimal;
|
precoUnitario: Prisma.Decimal;
|
||||||
|
descontoPerc: Prisma.Decimal;
|
||||||
|
total: Prisma.Decimal;
|
||||||
}[];
|
}[];
|
||||||
history: {
|
historico: {
|
||||||
id: string;
|
id: string;
|
||||||
fromStatus: OrderStatus | null;
|
situaAnterior: number | null;
|
||||||
toStatus: OrderStatus;
|
situaNova: number;
|
||||||
changedById: string;
|
changedBy: number;
|
||||||
note: string | null;
|
nota: string | null;
|
||||||
changedAt: Date;
|
changedAt: Date;
|
||||||
}[];
|
}[];
|
||||||
}): OrderDetail {
|
}): PedidoDetail {
|
||||||
return {
|
return {
|
||||||
id: o.id,
|
id: o.id,
|
||||||
number: o.number,
|
numPedSar: o.numPedSar,
|
||||||
clientId: o.clientId,
|
idCliente: o.idCliente,
|
||||||
clientName: o.client.name,
|
codVendedor: o.codVendedor,
|
||||||
repId: o.repId,
|
situa: o.situa,
|
||||||
status: o.status,
|
dtPedido: o.dtPedido.toISOString(),
|
||||||
discountPct: decimalToString(o.discountPct),
|
|
||||||
subtotal: decimalToString(o.subtotal),
|
|
||||||
total: decimalToString(o.total),
|
total: decimalToString(o.total),
|
||||||
notes: o.notes,
|
descontoPerc: decimalToString(o.descontoPerc),
|
||||||
approvedById: o.approvedById,
|
obs: o.obs,
|
||||||
idempotencyKey: o.idempotencyKey,
|
|
||||||
issuedAt: o.issuedAt.toISOString(),
|
|
||||||
approvedAt: o.approvedAt?.toISOString() ?? null,
|
|
||||||
invoicedAt: o.invoicedAt?.toISOString() ?? null,
|
|
||||||
cancelledAt: o.cancelledAt?.toISOString() ?? null,
|
|
||||||
createdAt: o.createdAt.toISOString(),
|
createdAt: o.createdAt.toISOString(),
|
||||||
|
totalProdutos: decimalToString(o.totalProdutos),
|
||||||
|
totalIpi: decimalToString(o.totalIpi),
|
||||||
|
totalIcmsst: decimalToString(o.totalIcmsst),
|
||||||
|
descontoValor: decimalToString(o.descontoValor),
|
||||||
|
acrescimo: decimalToString(o.acrescimo),
|
||||||
|
comissao: decimalToString(o.comissao),
|
||||||
|
pedFlex: decimalToString(o.pedFlex),
|
||||||
|
aprovadoPor: o.aprovadoPor,
|
||||||
|
aprovadoEm: o.aprovadoEm?.toISOString() ?? null,
|
||||||
|
motivoRecusa: o.motivoRecusa,
|
||||||
|
idempotencyKey: o.idempotencyKey,
|
||||||
updatedAt: o.updatedAt.toISOString(),
|
updatedAt: o.updatedAt.toISOString(),
|
||||||
items: o.items.map((it) => ({
|
itens: o.itens.map((it) => ({
|
||||||
id: it.id,
|
id: it.id,
|
||||||
productCode: it.productCode,
|
idProduto: it.idProduto,
|
||||||
productName: it.productName,
|
codProduto: it.codProduto,
|
||||||
quantity: decimalToString(it.quantity),
|
descProduto: it.descProduto,
|
||||||
unitPrice: decimalToString(it.unitPrice),
|
ordem: it.ordem,
|
||||||
discountPct: decimalToString(it.discountPct),
|
qtd: decimalToString(it.qtd),
|
||||||
subtotal: decimalToString(it.subtotal),
|
precoUnitario: decimalToString(it.precoUnitario),
|
||||||
|
descontoPerc: decimalToString(it.descontoPerc),
|
||||||
|
total: decimalToString(it.total),
|
||||||
})),
|
})),
|
||||||
history: o.history.map((h) => ({
|
historico: o.historico.map((h) => ({
|
||||||
id: h.id,
|
id: h.id,
|
||||||
fromStatus: h.fromStatus,
|
situaAnterior: h.situaAnterior,
|
||||||
toStatus: h.toStatus,
|
situaNova: h.situaNova,
|
||||||
changedById: h.changedById,
|
changedBy: h.changedBy,
|
||||||
note: h.note,
|
nota: h.nota,
|
||||||
changedAt: h.changedAt.toISOString(),
|
changedAt: h.changedAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Últimos N pedidos de um cliente — usado na ficha (FR-2.4).
|
|
||||||
async listByClient(
|
|
||||||
clientId: string,
|
|
||||||
userId: string,
|
|
||||||
role: string,
|
|
||||||
limit = 10,
|
|
||||||
): Promise<OrderSummary[]> {
|
|
||||||
const prisma = this.cls.get('prisma');
|
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
|
||||||
|
|
||||||
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
|
|
||||||
|
|
||||||
const rows = await prisma.order.findMany({
|
|
||||||
where: { clientId, deletedAt: null, ...repFilter },
|
|
||||||
include: { client: { select: { name: true } } },
|
|
||||||
orderBy: { issuedAt: 'desc' },
|
|
||||||
take: limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
return rows.map((o) => ({
|
|
||||||
id: o.id,
|
|
||||||
number: o.number,
|
|
||||||
clientId: o.clientId,
|
|
||||||
clientName: o.client.name,
|
|
||||||
repId: o.repId,
|
|
||||||
status: o.status,
|
|
||||||
discountPct: decimalToString(o.discountPct),
|
|
||||||
subtotal: decimalToString(o.subtotal),
|
|
||||||
total: decimalToString(o.total),
|
|
||||||
issuedAt: o.issuedAt.toISOString(),
|
|
||||||
approvedAt: o.approvedAt?.toISOString() ?? null,
|
|
||||||
invoicedAt: o.invoicedAt?.toISOString() ?? null,
|
|
||||||
cancelledAt: o.cancelledAt?.toISOString() ?? null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class PingController {
|
|||||||
status: 'ok',
|
status: 'ok',
|
||||||
service: 'sar-api',
|
service: 'sar-api',
|
||||||
version: process.env['npm_package_version'] ?? '0.1.0',
|
version: process.env['npm_package_version'] ?? '0.1.0',
|
||||||
workspaceId: this.cls.get('workspaceId'),
|
idEmpresa: this.cls.get('idEmpresa'),
|
||||||
requestId: this.cls.get('requestId'),
|
requestId: this.cls.get('requestId'),
|
||||||
uptimeSeconds: Math.round(process.uptime()),
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
now: new Date().toISOString(),
|
now: new Date().toISOString(),
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
|
|
||||||
// ADR 0006: BD-por-workspace — um PrismaClient por workspaceId, nunca singleton.
|
// ADR 0006 revogado: BD-por-workspace → schema `sar` no ERP.
|
||||||
|
// Pool keyed por idEmpresa (number). URL deve incluir ?schema=sar.
|
||||||
// CODING-RULES PGD-DB-009: callers obtêm o client via CLS, não injetando este serviço.
|
// CODING-RULES PGD-DB-009: callers obtêm o client via CLS, não injetando este serviço.
|
||||||
|
|
||||||
const MAX_ENTRIES = 10; // LRU cap; ajustável via env na próxima iteração
|
const MAX_ENTRIES = 10; // LRU cap; ajustável via env na próxima iteração
|
||||||
@@ -22,12 +23,13 @@ export class WorkspacePrismaPool implements OnModuleDestroy {
|
|||||||
// Map preserves insertion order → LRU: primeiro = mais antigo, último = mais recente
|
// Map preserves insertion order → LRU: primeiro = mais antigo, último = mais recente
|
||||||
private readonly cache = new Map<string, PoolEntry>();
|
private readonly cache = new Map<string, PoolEntry>();
|
||||||
|
|
||||||
getOrCreate(workspaceId: string, dbUrl: string): PrismaClient {
|
getOrCreate(idEmpresa: number, dbUrl: string): PrismaClient {
|
||||||
const hit = this.cache.get(workspaceId);
|
const key = String(idEmpresa);
|
||||||
|
const hit = this.cache.get(key);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
// Move para o fim (LRU refresh)
|
// Move para o fim (LRU refresh)
|
||||||
this.cache.delete(workspaceId);
|
this.cache.delete(key);
|
||||||
this.cache.set(workspaceId, hit);
|
this.cache.set(key, hit);
|
||||||
return hit.client;
|
return hit.client;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,13 +40,13 @@ export class WorkspacePrismaPool implements OnModuleDestroy {
|
|||||||
const pgPool = new pg.Pool({ connectionString: dbUrl, max: PG_POOL_SIZE });
|
const pgPool = new pg.Pool({ connectionString: dbUrl, max: PG_POOL_SIZE });
|
||||||
const adapter = new PrismaPg(pgPool);
|
const adapter = new PrismaPg(pgPool);
|
||||||
const client = new PrismaClient({ adapter });
|
const client = new PrismaClient({ adapter });
|
||||||
this.cache.set(workspaceId, { client, pgPool });
|
this.cache.set(key, { client, pgPool });
|
||||||
this.logger.log(`pool criado: workspace=${workspaceId} total=${this.cache.size}`);
|
this.logger.log(`pool criado: idEmpresa=${idEmpresa} total=${this.cache.size}`);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
async health(k = 3): Promise<{ workspaceId: string; ok: boolean; latencyMs?: number }[]> {
|
async health(k = 3): Promise<{ workspaceId: string; ok: boolean; latencyMs?: number }[]> {
|
||||||
// Verifica os k workspaces mais recentes
|
// Verifica os k empresas mais recentes
|
||||||
const entries = [...this.cache.entries()].slice(-k);
|
const entries = [...this.cache.entries()].slice(-k);
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
entries.map(async ([workspaceId, { pgPool }]) => {
|
entries.map(async ([workspaceId, { pgPool }]) => {
|
||||||
@@ -75,6 +77,6 @@ export class WorkspacePrismaPool implements OnModuleDestroy {
|
|||||||
void oldest.client.$disconnect().catch(noop);
|
void oldest.client.$disconnect().catch(noop);
|
||||||
void oldest.pgPool.end().catch(noop);
|
void oldest.pgPool.end().catch(noop);
|
||||||
this.cache.delete(oldestId);
|
this.cache.delete(oldestId);
|
||||||
this.logger.log(`evicted LRU workspace=${oldestId}`);
|
this.logger.log(`evicted LRU idEmpresa=${oldestId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { WorkspaceClsStore } from './workspace.types';
|
import type { WorkspaceClsStore } from './workspace.types';
|
||||||
import type { Env } from '../config/env.schema';
|
|
||||||
import { WorkspacePrismaPool } from './workspace-prisma-pool.service';
|
import { WorkspacePrismaPool } from './workspace-prisma-pool.service';
|
||||||
|
|
||||||
// CLS middleware roda ANTES dos guards (ordem NestJS).
|
// CLS middleware roda ANTES dos guards (ordem NestJS).
|
||||||
// Aqui: apenas requestId + workspaceId default.
|
// Aqui: apenas requestId + idEmpresa default (0 = não autenticado).
|
||||||
// JwtAuthGuard atualiza workspaceId, userId e prisma após validar o token.
|
// JwtAuthGuard atualiza idEmpresa, userId e prisma após validar o token.
|
||||||
// CODING-RULES PGD-DB-009: prisma via cls.get('prisma'), nunca singleton.
|
// CODING-RULES PGD-DB-009: prisma via cls.get('prisma'), nunca singleton.
|
||||||
// CODING-RULES PGD-AUTHZ-002: workspaceId real vem do JWT (guard), não do env.
|
// CODING-RULES PGD-AUTHZ-002: idEmpresa real vem do JWT (guard), não do env.
|
||||||
|
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ClsModule.forRootAsync({
|
ClsModule.forRootAsync({
|
||||||
global: true,
|
global: true,
|
||||||
imports: [ConfigModule],
|
useFactory: () => ({
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService<Env, true>) => ({
|
|
||||||
middleware: {
|
middleware: {
|
||||||
mount: true,
|
mount: true,
|
||||||
generateId: true,
|
generateId: true,
|
||||||
@@ -39,7 +36,7 @@ import { WorkspacePrismaPool } from './workspace-prisma-pool.service';
|
|||||||
res.setHeader('x-request-id', requestId);
|
res.setHeader('x-request-id', requestId);
|
||||||
store.set('requestId', requestId);
|
store.set('requestId', requestId);
|
||||||
// Fallback para rotas públicas (ping, health). Guard sobrescreve em rotas protegidas.
|
// Fallback para rotas públicas (ping, health). Guard sobrescreve em rotas protegidas.
|
||||||
store.set('workspaceId', config.get('DEFAULT_WORKSPACE_ID', { infer: true }));
|
store.set('idEmpresa', 0);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import type { JwtRole } from '../auth/jwt.types';
|
|||||||
|
|
||||||
// Forma do CLS store por request — fonte da verdade para qualquer caller.
|
// Forma do CLS store por request — fonte da verdade para qualquer caller.
|
||||||
// CODING-RULES PGD-DB-009: nunca importe PrismaClient diretamente; use cls.get('prisma').
|
// CODING-RULES PGD-DB-009: nunca importe PrismaClient diretamente; use cls.get('prisma').
|
||||||
// CODING-RULES PGD-AUTHZ-002: workspaceId vem sempre do JWT, nunca de body/param/query.
|
// CODING-RULES PGD-AUTHZ-002: idEmpresa vem sempre do JWT, nunca de body/param/query.
|
||||||
|
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
|
||||||
|
|
||||||
export interface WorkspaceClsStore extends ClsStore {
|
export interface WorkspaceClsStore extends ClsStore {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
workspaceId: string;
|
idEmpresa: number; // era workspaceId: string — agora Int da empresa no ERP
|
||||||
userId?: string; // preenchido pelo JwtAuthGuard após validar o token
|
userId?: string; // cod_vendedor como string; preenchido pelo JwtAuthGuard
|
||||||
role?: JwtRole; // preenchido pelo JwtAuthGuard após validar o token
|
role?: JwtRole; // preenchido pelo JwtAuthGuard após validar o token
|
||||||
prisma?: PrismaClient; // preenchido pelo JwtAuthGuard via WorkspacePrismaPool
|
prisma?: PrismaClient; // preenchido pelo JwtAuthGuard via WorkspacePrismaPool
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/api/tsconfig.tsbuildinfo
Normal file
1
apps/api/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"fileNames":[],"fileInfos":[],"root":[],"options":{"allowSyntheticDefaultImports":true,"composite":false,"declaration":true,"declarationMap":true,"emitDecoratorMetadata":true,"esModuleInterop":true,"experimentalDecorators":true,"module":199,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noUncheckedIndexedAccess":true,"skipLibCheck":true,"sourceMap":true,"strict":true,"target":10,"useDefineForClassFields":false,"verbatimModuleSyntax":false},"version":"5.9.3"}
|
||||||
@@ -1,22 +1,13 @@
|
|||||||
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
|
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
||||||
import type { OrderSummary, OrderStatus } from '@sar/api-interface';
|
import type { PedidoSummary } from '@sar/api-interface';
|
||||||
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
import { useClientDetail } from '../../lib/queries/clients';
|
import { useClientDetail } from '../../lib/queries/clients';
|
||||||
import { useClientOrders } from '../../lib/queries/orders';
|
import { useClientOrders } from '../../lib/queries/orders';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
const FINANCIAL_COLOR: Record<string, string> = {
|
|
||||||
regular: 'success',
|
|
||||||
attention: 'warning',
|
|
||||||
blocked: 'error',
|
|
||||||
};
|
|
||||||
const FINANCIAL_LABEL: Record<string, string> = {
|
|
||||||
regular: 'Regular',
|
|
||||||
attention: 'Atenção',
|
|
||||||
blocked: 'Bloqueado',
|
|
||||||
};
|
|
||||||
const ACTIVITY_COLOR: Record<string, string> = {
|
const ACTIVITY_COLOR: Record<string, string> = {
|
||||||
active: 'success',
|
active: 'success',
|
||||||
alert: 'warning',
|
alert: 'warning',
|
||||||
@@ -27,27 +18,13 @@ const ACTIVITY_LABEL: Record<string, string> = {
|
|||||||
alert: 'Alerta',
|
alert: 'Alerta',
|
||||||
inactive: 'Inativo',
|
inactive: 'Inativo',
|
||||||
};
|
};
|
||||||
const STATUS_LABEL: Record<OrderStatus, string> = {
|
|
||||||
budget: 'Orçamento',
|
|
||||||
pending_approval: 'Ag. Aprovação',
|
|
||||||
approved: 'Aprovado',
|
|
||||||
invoiced: 'Faturado',
|
|
||||||
cancelled: 'Cancelado',
|
|
||||||
};
|
|
||||||
const STATUS_COLOR: Record<OrderStatus, string> = {
|
|
||||||
budget: 'default',
|
|
||||||
pending_approval: 'warning',
|
|
||||||
approved: 'processing',
|
|
||||||
invoiced: 'success',
|
|
||||||
cancelled: 'error',
|
|
||||||
};
|
|
||||||
|
|
||||||
const orderColumns: TableColumnsType<OrderSummary> = [
|
const orderColumns: TableColumnsType<PedidoSummary> = [
|
||||||
{
|
{
|
||||||
title: 'Nº',
|
title: 'Nº',
|
||||||
dataIndex: 'number',
|
dataIndex: 'numPedSar',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (num: string, row: OrderSummary) => (
|
render: (num: string, row: PedidoSummary) => (
|
||||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
{num}
|
{num}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -55,9 +32,17 @@ const orderColumns: TableColumnsType<OrderSummary> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Status',
|
title: 'Status',
|
||||||
dataIndex: 'status',
|
dataIndex: 'situa',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (s: OrderStatus) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s]}</Tag>,
|
render: (s: number) => {
|
||||||
|
const colorMap: Record<number, string> = {
|
||||||
|
1: 'warning',
|
||||||
|
2: 'processing',
|
||||||
|
3: 'error',
|
||||||
|
4: 'success',
|
||||||
|
};
|
||||||
|
return <Tag color={colorMap[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total',
|
title: 'Total',
|
||||||
@@ -68,8 +53,8 @@ const orderColumns: TableColumnsType<OrderSummary> = [
|
|||||||
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Emitido em',
|
title: 'Data',
|
||||||
dataIndex: 'issuedAt',
|
dataIndex: 'dtPedido',
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
},
|
},
|
||||||
@@ -77,85 +62,74 @@ const orderColumns: TableColumnsType<OrderSummary> = [
|
|||||||
|
|
||||||
export function ClientDetailPage() {
|
export function ClientDetailPage() {
|
||||||
const { id } = useParams({ from: '/clientes/$id' });
|
const { id } = useParams({ from: '/clientes/$id' });
|
||||||
|
const idNum = Number(id);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(id);
|
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum);
|
||||||
const { data: orders, isLoading: ordersLoading } = useClientOrders(id);
|
const { data: orders, isLoading: ordersLoading } = useClientOrders(idNum);
|
||||||
|
|
||||||
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||||
if (clientError || !client)
|
if (clientError || !client)
|
||||||
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
|
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
|
||||||
|
|
||||||
const addr = client.address;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
||||||
<Link to="/clientes">← Clientes</Link>
|
<Link to="/clientes">← Clientes</Link>
|
||||||
<Title level={3} style={{ margin: 0 }}>
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
{client.tradeName ?? client.name}
|
{client.razao ?? client.nome}
|
||||||
</Title>
|
</Title>
|
||||||
<Tag color={FINANCIAL_COLOR[client.financialStatus]}>
|
|
||||||
{FINANCIAL_LABEL[client.financialStatus]}
|
|
||||||
</Tag>
|
|
||||||
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
|
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
|
||||||
{ACTIVITY_LABEL[client.activityStatus]}
|
{ACTIVITY_LABEL[client.activityStatus]}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
|
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
|
||||||
disabled={client.financialStatus === 'blocked'}
|
|
||||||
>
|
>
|
||||||
Novo Pedido
|
Novo Pedido
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
|
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
|
||||||
<Descriptions.Item label="Razão Social">{client.name}</Descriptions.Item>
|
<Descriptions.Item label="Razão Social">{client.nome}</Descriptions.Item>
|
||||||
<Descriptions.Item label="CNPJ">{client.taxId}</Descriptions.Item>
|
<Descriptions.Item label="CNPJ / CPF">{client.cgcpf ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Telefone">{client.phone ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="Telefone">
|
||||||
{addr && (
|
{client.ddd ? `(${client.ddd}) ` : ''}
|
||||||
|
{client.telefone ?? '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
{client.endereco && (
|
||||||
<Descriptions.Item label="Endereço" span={2}>
|
<Descriptions.Item label="Endereço" span={2}>
|
||||||
{addr.street}, {addr.number}
|
{client.endereco}
|
||||||
{addr.complement ? `, ${addr.complement}` : ''} — {addr.district}, {addr.city}/
|
{client.numEndereco ? `, ${client.numEndereco}` : ''}
|
||||||
{addr.state} — CEP {addr.zip}
|
{client.bairro ? ` — ${client.bairro}` : ''}
|
||||||
|
{client.cep ? ` — CEP ${client.cep}` : ''}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
<Descriptions.Item label="Limite de Crédito">
|
<Descriptions.Item label="Limite de Crédito">
|
||||||
{client.creditLimit
|
{client.limiteCreditoStr
|
||||||
? Number(client.creditLimit).toLocaleString('pt-BR', {
|
? Number(client.limiteCreditoStr).toLocaleString('pt-BR', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'BRL',
|
currency: 'BRL',
|
||||||
})
|
})
|
||||||
: '—'}
|
: '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="Pedidos em Aberto">{client.openOrdersCount}</Descriptions.Item>
|
<Descriptions.Item label="Última Compra">
|
||||||
<Descriptions.Item label="Último Pedido">
|
{client.dtUltimaCompra
|
||||||
{client.lastOrderAt ? new Date(client.lastOrderAt).toLocaleDateString('pt-BR') : '—'}
|
? new Date(client.dtUltimaCompra).toLocaleDateString('pt-BR')
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Valor Último Pedido">
|
|
||||||
{client.lastOrderValue
|
|
||||||
? Number(client.lastOrderValue).toLocaleString('pt-BR', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'BRL',
|
|
||||||
})
|
|
||||||
: '—'}
|
: '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
{client.erpCode && (
|
|
||||||
<Descriptions.Item label="Código ERP">{client.erpCode}</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Divider orientation="left">Últimos 10 Pedidos</Divider>
|
<Divider orientation="left">Últimos Pedidos</Divider>
|
||||||
|
|
||||||
<Table<OrderSummary>
|
<Table<PedidoSummary>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={orderColumns}
|
columns={orderColumns}
|
||||||
dataSource={orders ?? []}
|
dataSource={orders ?? []}
|
||||||
loading={ordersLoading}
|
loading={ordersLoading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
rowClassName={(row) => (row.status === 'pending_approval' ? 'row-pending' : '')}
|
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
|
||||||
/>
|
/>
|
||||||
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
|
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Badge, Input, Select, Space, Table, Tag, Tooltip, Typography } from 'antd';
|
import { Badge, Input, Select, Space, Table, Typography } from 'antd';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import type { ActivityStatus, ClientSummary, FinancialStatus } from '@sar/api-interface';
|
import type { ActivityStatus, ClientSummary } from '@sar/api-interface';
|
||||||
import { useClientList } from '../../lib/queries/clients';
|
import { useClientList } from '../../lib/queries/clients';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
@@ -16,31 +16,27 @@ const ACTIVITY_CONFIG: Record<ActivityStatus, { color: string; label: string }>
|
|||||||
inactive: { color: 'error', label: 'Inativo' },
|
inactive: { color: 'error', label: 'Inativo' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const FINANCIAL_CONFIG: Record<FinancialStatus, { color: string; label: string }> = {
|
|
||||||
regular: { color: 'success', label: 'Regular' },
|
|
||||||
attention: { color: 'warning', label: 'Atenção' },
|
|
||||||
blocked: { color: 'error', label: 'Bloqueado' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Columns ──────────────────────────────────────────────────────────────────
|
// ─── Columns ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildColumns(navigate: ReturnType<typeof useNavigate>): TableColumnsType<ClientSummary> {
|
function buildColumns(navigate: ReturnType<typeof useNavigate>): TableColumnsType<ClientSummary> {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: 'Cliente',
|
title: 'Cliente',
|
||||||
dataIndex: 'name',
|
dataIndex: 'nome',
|
||||||
key: 'name',
|
key: 'nome',
|
||||||
render: (name: string, record: ClientSummary) => (
|
render: (nome: string, record: ClientSummary) => (
|
||||||
<Space direction="vertical" size={0}>
|
<Space direction="vertical" size={0}>
|
||||||
<Typography.Link
|
<Typography.Link
|
||||||
strong
|
strong
|
||||||
onClick={() => navigate({ to: '/clientes/$id', params: { id: record.id } })}
|
onClick={() =>
|
||||||
|
navigate({ to: '/clientes/$id', params: { id: String(record.idCliente) } })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{name}
|
{nome}
|
||||||
</Typography.Link>
|
</Typography.Link>
|
||||||
{record.tradeName && (
|
{record.razao && (
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{record.tradeName}
|
{record.razao}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -49,12 +45,12 @@ function buildColumns(navigate: ReturnType<typeof useNavigate>): TableColumnsTyp
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'CNPJ / CPF',
|
title: 'CNPJ / CPF',
|
||||||
dataIndex: 'taxId',
|
dataIndex: 'cgcpf',
|
||||||
key: 'taxId',
|
key: 'cgcpf',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (v: string) => (
|
render: (v: string | null) => (
|
||||||
<Typography.Text className="tabular-nums" style={{ fontSize: 13 }}>
|
<Typography.Text className="tabular-nums" style={{ fontSize: 13 }}>
|
||||||
{v}
|
{v ?? '—'}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -68,49 +64,20 @@ function buildColumns(navigate: ReturnType<typeof useNavigate>): TableColumnsTyp
|
|||||||
return <Badge status={cfg.color as 'success' | 'warning' | 'error'} text={cfg.label} />;
|
return <Badge status={cfg.color as 'success' | 'warning' | 'error'} text={cfg.label} />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Situação',
|
|
||||||
dataIndex: 'financialStatus',
|
|
||||||
key: 'financialStatus',
|
|
||||||
width: 110,
|
|
||||||
render: (v: FinancialStatus) => {
|
|
||||||
const cfg = FINANCIAL_CONFIG[v];
|
|
||||||
return <Tag color={cfg.color}>{cfg.label}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Última compra',
|
title: 'Última compra',
|
||||||
dataIndex: 'lastOrderAt',
|
dataIndex: 'dtUltimaCompra',
|
||||||
key: 'lastOrderAt',
|
key: 'dtUltimaCompra',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (v: string | null, record: ClientSummary) => {
|
render: (v: string | null) => {
|
||||||
if (!v) return <Typography.Text type="secondary">—</Typography.Text>;
|
if (!v) return <Typography.Text type="secondary">—</Typography.Text>;
|
||||||
const date = new Date(v).toLocaleDateString('pt-BR');
|
|
||||||
const value = record.lastOrderValue
|
|
||||||
? `R$ ${Number(record.lastOrderValue).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`
|
|
||||||
: '';
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={value}>
|
<Typography.Text className="tabular-nums">
|
||||||
<Typography.Text className="tabular-nums">{date}</Typography.Text>
|
{new Date(v).toLocaleDateString('pt-BR')}
|
||||||
</Tooltip>
|
</Typography.Text>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Pedidos abertos',
|
|
||||||
dataIndex: 'openOrdersCount',
|
|
||||||
key: 'openOrdersCount',
|
|
||||||
width: 120,
|
|
||||||
align: 'center',
|
|
||||||
render: (v: number) =>
|
|
||||||
v > 0 ? (
|
|
||||||
<Tag color="processing" className="tabular-nums">
|
|
||||||
{v}
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">—</Typography.Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +108,7 @@ export function ClientsPage() {
|
|||||||
Carteira de Clientes
|
Carteira de Clientes
|
||||||
</Title>
|
</Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
{data ? `${data.total} cliente${data.total !== 1 ? 's' : ''} na sua carteira` : ' '}
|
{data ? `${data.total} cliente${data.total !== 1 ? 's' : ''} na sua carteira` : ' '}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
@@ -179,7 +146,7 @@ export function ClientsPage() {
|
|||||||
<Table<ClientSummary>
|
<Table<ClientSummary>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.data ?? []}
|
dataSource={data?.data ?? []}
|
||||||
rowKey="id"
|
rowKey="idCliente"
|
||||||
loading={isLoading || isFetching}
|
loading={isLoading || isFetching}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
@@ -189,11 +156,12 @@ export function ClientsPage() {
|
|||||||
showTotal: (total) => `${total} clientes`,
|
showTotal: (total) => `${total} clientes`,
|
||||||
onChange: (p) => setPage(p),
|
onChange: (p) => setPage(p),
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 900 }}
|
scroll={{ x: 700 }}
|
||||||
size="middle"
|
size="middle"
|
||||||
onRow={(record) => ({
|
onRow={(record) => ({
|
||||||
style: { cursor: 'pointer' },
|
style: { cursor: 'pointer' },
|
||||||
onClick: () => navigate({ to: '/clientes/$id', params: { id: record.id } }),
|
onClick: () =>
|
||||||
|
navigate({ to: '/clientes/$id', params: { id: String(record.idCliente) } }),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||||
import type { CreateOrder, CreateOrderItem, ProductSummary } from '@sar/api-interface';
|
import type { CreatePedido, CreatePedidoItem, ProdutoSummary } from '@sar/api-interface';
|
||||||
import { useClientDetail } from '../../lib/queries/clients';
|
import { useClientDetail } from '../../lib/queries/clients';
|
||||||
import { useCatalog } from '../../lib/queries/catalog';
|
import { useCatalog } from '../../lib/queries/catalog';
|
||||||
import { apiFetch } from '../../lib/api-client';
|
import { apiFetch } from '../../lib/api-client';
|
||||||
@@ -26,7 +26,7 @@ import { apiFetch } from '../../lib/api-client';
|
|||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
|
|
||||||
type CartItem = CreateOrderItem & { key: string };
|
type CartItem = CreatePedidoItem & { key: string };
|
||||||
|
|
||||||
function calcItemTotal(qty: number, price: number, disc: number): number {
|
function calcItemTotal(qty: number, price: number, disc: number): number {
|
||||||
return Math.round(qty * price * (1 - disc / 100) * 100) / 100;
|
return Math.round(qty * price * (1 - disc / 100) * 100) / 100;
|
||||||
@@ -46,7 +46,7 @@ function ProductStep({
|
|||||||
onDiscChange,
|
onDiscChange,
|
||||||
}: {
|
}: {
|
||||||
cart: CartItem[];
|
cart: CartItem[];
|
||||||
onAdd: (p: ProductSummary) => void;
|
onAdd: (p: ProdutoSummary) => void;
|
||||||
onRemove: (key: string) => void;
|
onRemove: (key: string) => void;
|
||||||
onQtyChange: (key: string, qty: number) => void;
|
onQtyChange: (key: string, qty: number) => void;
|
||||||
onDiscChange: (key: string, disc: number) => void;
|
onDiscChange: (key: string, disc: number) => void;
|
||||||
@@ -54,20 +54,20 @@ function ProductStep({
|
|||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const { data, isLoading } = useCatalog({ q: q || undefined, limit: 20 });
|
const { data, isLoading } = useCatalog({ q: q || undefined, limit: 20 });
|
||||||
|
|
||||||
const cartKeys = new Set(cart.map((c) => c.productCode));
|
const cartKeys = new Set(cart.map((c) => String(c.idProduto)));
|
||||||
|
|
||||||
const catalogColumns: TableColumnsType<ProductSummary> = [
|
const catalogColumns: TableColumnsType<ProdutoSummary> = [
|
||||||
{ title: 'Código', dataIndex: 'code', width: 100 },
|
{ title: 'Código', dataIndex: 'codigo', width: 100 },
|
||||||
{ title: 'Produto', dataIndex: 'name', ellipsis: true },
|
{ title: 'Produto', dataIndex: 'descricao', ellipsis: true },
|
||||||
{
|
{
|
||||||
title: 'Categoria',
|
title: 'Grupo',
|
||||||
dataIndex: 'category',
|
dataIndex: 'grupo',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v: string) => <Tag>{v}</Tag>,
|
render: (v: string | null) => (v ? <Tag>{v}</Tag> : null),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Preço',
|
title: 'Preço',
|
||||||
dataIndex: 'unitPrice',
|
dataIndex: 'vlPreco1',
|
||||||
width: 110,
|
width: 110,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (v: string) => fmt(Number(v)),
|
render: (v: string) => fmt(Number(v)),
|
||||||
@@ -75,11 +75,11 @@ function ProductStep({
|
|||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (_: unknown, row: ProductSummary) => (
|
render: (_: unknown, row: ProdutoSummary) => (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
disabled={cartKeys.has(row.code)}
|
disabled={cartKeys.has(String(row.idErp))}
|
||||||
onClick={() => onAdd(row)}
|
onClick={() => onAdd(row)}
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
@@ -89,10 +89,10 @@ function ProductStep({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const cartColumns: TableColumnsType<CartItem> = [
|
const cartColumns: TableColumnsType<CartItem> = [
|
||||||
{ title: 'Produto', dataIndex: 'productName', ellipsis: true },
|
{ title: 'Produto', dataIndex: 'descProduto', ellipsis: true },
|
||||||
{
|
{
|
||||||
title: 'Qtd',
|
title: 'Qtd',
|
||||||
dataIndex: 'quantity',
|
dataIndex: 'qtd',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: number, row: CartItem) => (
|
render: (v: number, row: CartItem) => (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
@@ -107,7 +107,7 @@ function ProductStep({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Desc %',
|
title: 'Desc %',
|
||||||
dataIndex: 'discountPct',
|
dataIndex: 'descontoPerc',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v: number, row: CartItem) => (
|
render: (v: number, row: CartItem) => (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
@@ -126,7 +126,7 @@ function ProductStep({
|
|||||||
width: 120,
|
width: 120,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (_: unknown, row: CartItem) =>
|
render: (_: unknown, row: CartItem) =>
|
||||||
fmt(calcItemTotal(row.quantity, row.unitPrice, row.discountPct)),
|
fmt(calcItemTotal(row.qtd, row.precoUnitario, row.descontoPerc)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
@@ -148,8 +148,8 @@ function ProductStep({
|
|||||||
if (!e.target.value) setQ('');
|
if (!e.target.value) setQ('');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Table<ProductSummary>
|
<Table<ProdutoSummary>
|
||||||
rowKey="id"
|
rowKey="idErp"
|
||||||
columns={catalogColumns}
|
columns={catalogColumns}
|
||||||
dataSource={data?.data ?? []}
|
dataSource={data?.data ?? []}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
@@ -175,20 +175,20 @@ function ProductStep({
|
|||||||
function ReviewStep({
|
function ReviewStep({
|
||||||
cart,
|
cart,
|
||||||
globalDisc,
|
globalDisc,
|
||||||
notes,
|
obs,
|
||||||
creditLimit,
|
creditLimit,
|
||||||
onDiscChange,
|
onDiscChange,
|
||||||
onNotesChange,
|
onObsChange,
|
||||||
}: {
|
}: {
|
||||||
cart: CartItem[];
|
cart: CartItem[];
|
||||||
globalDisc: number;
|
globalDisc: number;
|
||||||
notes: string;
|
obs: string;
|
||||||
creditLimit: string | null;
|
creditLimit: string | null;
|
||||||
onDiscChange: (v: number) => void;
|
onDiscChange: (v: number) => void;
|
||||||
onNotesChange: (v: string) => void;
|
onObsChange: (v: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const itemsSubtotal = cart.reduce(
|
const itemsSubtotal = cart.reduce(
|
||||||
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
|
(acc, it) => acc + calcItemTotal(it.qtd, it.precoUnitario, it.descontoPerc),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
|
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
|
||||||
@@ -228,8 +228,8 @@ function ReviewStep({
|
|||||||
rows={3}
|
rows={3}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
showCount
|
showCount
|
||||||
value={notes}
|
value={obs}
|
||||||
onChange={(e) => onNotesChange(e.target.value)}
|
onChange={(e) => onObsChange(e.target.value)}
|
||||||
placeholder="Instruções de entrega, referência do comprador, etc."
|
placeholder="Instruções de entrega, referência do comprador, etc."
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -242,16 +242,16 @@ function ReviewStep({
|
|||||||
function ConfirmStep({
|
function ConfirmStep({
|
||||||
cart,
|
cart,
|
||||||
globalDisc,
|
globalDisc,
|
||||||
notes,
|
obs,
|
||||||
clientName,
|
clientNome,
|
||||||
}: {
|
}: {
|
||||||
cart: CartItem[];
|
cart: CartItem[];
|
||||||
globalDisc: number;
|
globalDisc: number;
|
||||||
notes: string;
|
obs: string;
|
||||||
clientName: string;
|
clientNome: string;
|
||||||
}) {
|
}) {
|
||||||
const itemsSubtotal = cart.reduce(
|
const itemsSubtotal = cart.reduce(
|
||||||
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
|
(acc, it) => acc + calcItemTotal(it.qtd, it.precoUnitario, it.descontoPerc),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
|
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
|
||||||
@@ -259,7 +259,7 @@ function ConfirmStep({
|
|||||||
return (
|
return (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
<Descriptions bordered size="small" column={1}>
|
<Descriptions bordered size="small" column={1}>
|
||||||
<Descriptions.Item label="Cliente">{clientName}</Descriptions.Item>
|
<Descriptions.Item label="Cliente">{clientNome}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Produtos">{cart.length} item(ns)</Descriptions.Item>
|
<Descriptions.Item label="Produtos">{cart.length} item(ns)</Descriptions.Item>
|
||||||
<Descriptions.Item label="Subtotal dos itens">{fmt(itemsSubtotal)}</Descriptions.Item>
|
<Descriptions.Item label="Subtotal dos itens">{fmt(itemsSubtotal)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Desconto global">{globalDisc}%</Descriptions.Item>
|
<Descriptions.Item label="Desconto global">{globalDisc}%</Descriptions.Item>
|
||||||
@@ -268,11 +268,11 @@ function ConfirmStep({
|
|||||||
{fmt(total)}
|
{fmt(total)}
|
||||||
</Text>
|
</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
{notes && <Descriptions.Item label="Observações">{notes}</Descriptions.Item>}
|
{obs && <Descriptions.Item label="Observações">{obs}</Descriptions.Item>}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
message="O pedido será criado com status Orçamento ou Aguardando Aprovação, conforme a sua alçada de desconto."
|
message="O pedido será criado como Pendente de Aprovação ou Aprovado conforme a sua alçada de desconto."
|
||||||
showIcon
|
showIcon
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -288,61 +288,64 @@ export function NewOrderPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const { data: client, isLoading: clientLoading } = useClientDetail(clientId);
|
const clientIdNum = clientId ? Number(clientId) : undefined;
|
||||||
|
const { data: client, isLoading: clientLoading } = useClientDetail(clientIdNum);
|
||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [cart, setCart] = useState<CartItem[]>([]);
|
const [cart, setCart] = useState<CartItem[]>([]);
|
||||||
const [globalDisc, setGlobalDisc] = useState(0);
|
const [globalDisc, setGlobalDisc] = useState(0);
|
||||||
const [notes, setNotes] = useState('');
|
const [obs, setObs] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!clientId) throw new Error('clientId ausente');
|
if (!clientIdNum) throw new Error('clientId ausente');
|
||||||
const body: CreateOrder = {
|
const body: CreatePedido = {
|
||||||
clientId,
|
idCliente: clientIdNum,
|
||||||
discountPct: globalDisc,
|
descontoPerc: globalDisc,
|
||||||
notes: notes || undefined,
|
obs: obs || undefined,
|
||||||
idempotencyKey: crypto.randomUUID(),
|
idempotencyKey: crypto.randomUUID(),
|
||||||
items: cart.map((it) => ({
|
itens: cart.map((it, idx) => ({
|
||||||
productCode: it.productCode,
|
idProduto: it.idProduto,
|
||||||
productName: it.productName,
|
codProduto: it.codProduto,
|
||||||
productCategory: it.productCategory,
|
descProduto: it.descProduto,
|
||||||
quantity: it.quantity,
|
ordem: idx + 1,
|
||||||
unitPrice: it.unitPrice,
|
qtd: it.qtd,
|
||||||
discountPct: it.discountPct,
|
precoUnitario: it.precoUnitario,
|
||||||
|
descontoPerc: it.descontoPerc,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
return apiFetch('/orders', { method: 'POST', body });
|
return apiFetch('/orders', { method: 'POST', body });
|
||||||
},
|
},
|
||||||
onSuccess: (order: { id: string }) => {
|
onSuccess: (pedido: { id: string }) => {
|
||||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
void qc.invalidateQueries({ queryKey: ['clients', clientId] });
|
void qc.invalidateQueries({ queryKey: ['clients', clientIdNum] });
|
||||||
void navigate({ to: '/pedidos/$id', params: { id: order.id } });
|
void navigate({ to: '/pedidos/$id', params: { id: pedido.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'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const addToCart = (p: ProductSummary) => {
|
const addToCart = (p: ProdutoSummary) => {
|
||||||
setCart((prev) => [
|
setCart((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
key: p.code,
|
key: String(p.idErp),
|
||||||
productCode: p.code,
|
idProduto: p.idErp,
|
||||||
productName: p.name,
|
codProduto: p.codigo,
|
||||||
productCategory: p.category,
|
descProduto: p.descricao,
|
||||||
quantity: 1,
|
ordem: prev.length + 1,
|
||||||
unitPrice: Number(p.unitPrice),
|
qtd: 1,
|
||||||
discountPct: 0,
|
precoUnitario: Number(p.vlPreco1),
|
||||||
|
descontoPerc: 0,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromCart = (key: string) => setCart((prev) => prev.filter((it) => it.key !== key));
|
const removeFromCart = (key: string) => setCart((prev) => prev.filter((it) => it.key !== key));
|
||||||
const setQty = (key: string, qty: number) =>
|
const setQty = (key: string, qty: number) =>
|
||||||
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, quantity: qty } : it)));
|
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, qtd: qty } : it)));
|
||||||
const setDisc = (key: string, disc: number) =>
|
const setDisc = (key: string, disc: number) =>
|
||||||
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, discountPct: disc } : it)));
|
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, descontoPerc: disc } : it)));
|
||||||
|
|
||||||
if (!clientId)
|
if (!clientId)
|
||||||
return <Alert type="error" message="Parâmetro clientId ausente." style={{ margin: 24 }} />;
|
return <Alert type="error" message="Parâmetro clientId ausente." style={{ margin: 24 }} />;
|
||||||
@@ -360,7 +363,7 @@ export function NewOrderPage() {
|
|||||||
<div style={{ padding: 24, maxWidth: 900 }}>
|
<div style={{ padding: 24, maxWidth: 900 }}>
|
||||||
<Space align="center" style={{ marginBottom: 16 }}>
|
<Space align="center" style={{ marginBottom: 16 }}>
|
||||||
<Link to="/clientes/$id" params={{ id: clientId }}>
|
<Link to="/clientes/$id" params={{ id: clientId }}>
|
||||||
← {client.tradeName ?? client.name}
|
← {client.razao ?? client.nome}
|
||||||
</Link>
|
</Link>
|
||||||
<Title level={3} style={{ margin: 0 }}>
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
Novo Pedido
|
Novo Pedido
|
||||||
@@ -382,18 +385,18 @@ export function NewOrderPage() {
|
|||||||
<ReviewStep
|
<ReviewStep
|
||||||
cart={cart}
|
cart={cart}
|
||||||
globalDisc={globalDisc}
|
globalDisc={globalDisc}
|
||||||
notes={notes}
|
obs={obs}
|
||||||
creditLimit={client.creditLimit}
|
creditLimit={client.limiteCreditoStr}
|
||||||
onDiscChange={setGlobalDisc}
|
onDiscChange={setGlobalDisc}
|
||||||
onNotesChange={setNotes}
|
onObsChange={setObs}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<ConfirmStep
|
<ConfirmStep
|
||||||
cart={cart}
|
cart={cart}
|
||||||
globalDisc={globalDisc}
|
globalDisc={globalDisc}
|
||||||
notes={notes}
|
obs={obs}
|
||||||
clientName={client.tradeName ?? client.name}
|
clientNome={client.razao ?? client.nome}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { Link, useParams } from '@tanstack/react-router';
|
import { Link, useParams } from '@tanstack/react-router';
|
||||||
import type { OrderItem, OrderStatus, OrderStatusHistory } from '@sar/api-interface';
|
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
|
||||||
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
import { useOrderDetail } from '../../lib/queries/orders';
|
import { useOrderDetail } from '../../lib/queries/orders';
|
||||||
import { useClientOrders } from '../../lib/queries/orders';
|
import { useClientOrders } from '../../lib/queries/orders';
|
||||||
import { apiFetch } from '../../lib/api-client';
|
import { apiFetch } from '../../lib/api-client';
|
||||||
@@ -33,19 +34,11 @@ const { TextArea } = Input;
|
|||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STATUS_COLOR: Record<OrderStatus, string> = {
|
const SITUA_COLOR: Record<number, string> = {
|
||||||
budget: 'default',
|
1: 'warning',
|
||||||
pending_approval: 'warning',
|
2: 'processing',
|
||||||
approved: 'processing',
|
3: 'error',
|
||||||
invoiced: 'success',
|
4: 'success',
|
||||||
cancelled: 'error',
|
|
||||||
};
|
|
||||||
const STATUS_LABEL: Record<OrderStatus, string> = {
|
|
||||||
budget: 'Orçamento',
|
|
||||||
pending_approval: 'Ag. Aprovação',
|
|
||||||
approved: 'Aprovado',
|
|
||||||
invoiced: 'Faturado',
|
|
||||||
cancelled: 'Cancelado',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmt(v: string | number): string {
|
function fmt(v: string | number): string {
|
||||||
@@ -53,17 +46,17 @@ function fmt(v: string | number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildShareText(order: {
|
function buildShareText(order: {
|
||||||
number: string;
|
numPedSar: string;
|
||||||
clientName: string;
|
idCliente: number;
|
||||||
total: string;
|
total: string;
|
||||||
items: Array<{ productName: string; quantity: string; unitPrice: string }>;
|
itens: Array<{ descProduto: string | null; qtd: string; precoUnitario: string }>;
|
||||||
}): string {
|
}): string {
|
||||||
const lines = [
|
const lines = [
|
||||||
`*Pedido ${order.number} — ${order.clientName}*`,
|
`*Pedido ${order.numPedSar} — Cliente ${order.idCliente}*`,
|
||||||
'',
|
'',
|
||||||
...order.items.map(
|
...order.itens.map(
|
||||||
(it) =>
|
(it) =>
|
||||||
`• ${it.productName} × ${Number(it.quantity).toLocaleString('pt-BR')} — ${fmt(it.unitPrice)} un.`,
|
`• ${it.descProduto ?? '?'} × ${Number(it.qtd).toLocaleString('pt-BR')} — ${fmt(it.precoUnitario)} un.`,
|
||||||
),
|
),
|
||||||
'',
|
'',
|
||||||
`*Total: ${fmt(order.total)}*`,
|
`*Total: ${fmt(order.total)}*`,
|
||||||
@@ -84,56 +77,61 @@ function getRoleFromToken(): string {
|
|||||||
|
|
||||||
// ─── Subcomponents ────────────────────────────────────────────────────────────
|
// ─── Subcomponents ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const itemColumns: TableColumnsType<OrderItem> = [
|
const itemColumns: TableColumnsType<PedidoItem> = [
|
||||||
{ title: 'Código', dataIndex: 'productCode', width: 100 },
|
{ title: 'Código', dataIndex: 'codProduto', width: 100 },
|
||||||
{ title: 'Produto', dataIndex: 'productName', ellipsis: true },
|
{ title: 'Produto', dataIndex: 'descProduto', ellipsis: true },
|
||||||
{ title: 'Qtd', dataIndex: 'quantity', width: 90, align: 'right' },
|
{ title: 'Qtd', dataIndex: 'qtd', width: 90, align: 'right' },
|
||||||
{
|
{
|
||||||
title: 'Preço Unit.',
|
title: 'Preço Unit.',
|
||||||
dataIndex: 'unitPrice',
|
dataIndex: 'precoUnitario',
|
||||||
width: 120,
|
width: 120,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (v: string) => fmt(v),
|
render: (v: string) => fmt(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Desc %',
|
title: 'Desc %',
|
||||||
dataIndex: 'discountPct',
|
dataIndex: 'descontoPerc',
|
||||||
width: 80,
|
width: 80,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (v: string) => `${v}%`,
|
render: (v: string) => `${v}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Subtotal',
|
title: 'Total',
|
||||||
dataIndex: 'subtotal',
|
dataIndex: 'total',
|
||||||
width: 130,
|
width: 130,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (v: string) => fmt(v),
|
render: (v: string) => fmt(v),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function HistoryTimeline({ history }: { history: OrderStatusHistory[] }) {
|
function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
|
||||||
return (
|
return (
|
||||||
<Timeline
|
<Timeline
|
||||||
items={history.map((h) => ({
|
items={history.map((h) => ({
|
||||||
color:
|
color:
|
||||||
STATUS_COLOR[h.toStatus] === 'success'
|
SITUA_COLOR[h.situaNova] === 'success'
|
||||||
? 'green'
|
? 'green'
|
||||||
: STATUS_COLOR[h.toStatus] === 'warning'
|
: SITUA_COLOR[h.situaNova] === 'warning'
|
||||||
? 'orange'
|
? 'orange'
|
||||||
: STATUS_COLOR[h.toStatus] === 'error'
|
: SITUA_COLOR[h.situaNova] === 'error'
|
||||||
? 'red'
|
? 'red'
|
||||||
: 'blue',
|
: 'blue',
|
||||||
children: (
|
children: (
|
||||||
<div>
|
<div>
|
||||||
<Text strong>{STATUS_LABEL[h.toStatus]}</Text>
|
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
|
||||||
{h.fromStatus && <Text type="secondary"> (de {STATUS_LABEL[h.fromStatus]})</Text>}
|
{h.situaAnterior != null && (
|
||||||
|
<Text type="secondary">
|
||||||
|
{' '}
|
||||||
|
(de {SITUA_LABEL[h.situaAnterior] ?? String(h.situaAnterior)})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<br />
|
<br />
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{new Date(h.changedAt).toLocaleString('pt-BR')} — {h.changedById}
|
{new Date(h.changedAt).toLocaleString('pt-BR')} — cod. {h.changedBy}
|
||||||
</Text>
|
</Text>
|
||||||
{h.note && (
|
{h.nota && (
|
||||||
<div style={{ marginTop: 4 }}>
|
<div style={{ marginTop: 4 }}>
|
||||||
<Text italic>"{h.note}"</Text>
|
<Text italic>"{h.nota}"</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -154,18 +152,18 @@ function ApproveModal({
|
|||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
originalDiscount: string;
|
originalDiscount: string;
|
||||||
onConfirm: (discountPct?: number, note?: string) => void;
|
onConfirm: (descontoPerc?: number, nota?: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [disc, setDisc] = useState<number | null>(null);
|
const [disc, setDisc] = useState<number | null>(null);
|
||||||
const [note, setNote] = useState('');
|
const [nota, setNota] = useState('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Aprovar Pedido"
|
title="Aprovar Pedido"
|
||||||
open={open}
|
open={open}
|
||||||
onOk={() => onConfirm(disc ?? undefined, note || undefined)}
|
onOk={() => onConfirm(disc ?? undefined, nota || undefined)}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
okText="Confirmar Aprovação"
|
okText="Confirmar Aprovação"
|
||||||
cancelText="Voltar"
|
cancelText="Voltar"
|
||||||
@@ -190,8 +188,8 @@ function ApproveModal({
|
|||||||
<Form.Item label="Observação (opcional)">
|
<Form.Item label="Observação (opcional)">
|
||||||
<TextArea
|
<TextArea
|
||||||
rows={2}
|
rows={2}
|
||||||
value={note}
|
value={nota}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNota(e.target.value)}
|
||||||
maxLength={300}
|
maxLength={300}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -209,20 +207,20 @@ function RejectModal({
|
|||||||
loading,
|
loading,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onConfirm: (reason: string) => void;
|
onConfirm: (motivo: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [reason, setReason] = useState('');
|
const [motivo, setMotivo] = useState('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Recusar Pedido"
|
title="Recusar Pedido"
|
||||||
open={open}
|
open={open}
|
||||||
onOk={() => reason.trim() && onConfirm(reason.trim())}
|
onOk={() => motivo.trim() && onConfirm(motivo.trim())}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
okText="Confirmar Recusa"
|
okText="Confirmar Recusa"
|
||||||
okButtonProps={{ danger: true, disabled: !reason.trim() }}
|
okButtonProps={{ danger: true, disabled: !motivo.trim() }}
|
||||||
cancelText="Voltar"
|
cancelText="Voltar"
|
||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
>
|
>
|
||||||
@@ -230,8 +228,8 @@ function RejectModal({
|
|||||||
<Form.Item label="Motivo da recusa" required>
|
<Form.Item label="Motivo da recusa" required>
|
||||||
<TextArea
|
<TextArea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={reason}
|
value={motivo}
|
||||||
onChange={(e) => setReason(e.target.value)}
|
onChange={(e) => setMotivo(e.target.value)}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
showCount
|
showCount
|
||||||
placeholder="Informe o motivo para o representante..."
|
placeholder="Informe o motivo para o representante..."
|
||||||
@@ -248,13 +246,13 @@ export function OrderDetailPage() {
|
|||||||
const { id } = useParams({ from: '/pedidos/$id' });
|
const { id } = useParams({ from: '/pedidos/$id' });
|
||||||
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?.clientId);
|
const { data: clientOrders } = useClientOrders(order?.idCliente);
|
||||||
|
|
||||||
const role = getRoleFromToken();
|
const role = getRoleFromToken();
|
||||||
const canAct = role !== 'rep' && order?.status === 'pending_approval';
|
const canAct = role !== 'rep' && order?.situa === 1;
|
||||||
const canShare =
|
const canShare =
|
||||||
role === 'rep' &&
|
role === 'rep' &&
|
||||||
(order?.status === 'approved' || order?.status === 'invoiced') &&
|
(order?.situa === 2 || order?.situa === 4) &&
|
||||||
typeof navigator !== 'undefined' &&
|
typeof navigator !== 'undefined' &&
|
||||||
!!navigator.share;
|
!!navigator.share;
|
||||||
|
|
||||||
@@ -263,8 +261,8 @@ export function OrderDetailPage() {
|
|||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: ({ discountPct, note }: { discountPct?: number; note?: string }) =>
|
mutationFn: ({ descontoPerc, nota }: { descontoPerc?: number; nota?: string }) =>
|
||||||
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { discountPct, note } }),
|
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { descontoPerc, nota } }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setApproveOpen(false);
|
setApproveOpen(false);
|
||||||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||||||
@@ -277,8 +275,8 @@ export function OrderDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rejectMutation = useMutation({
|
const rejectMutation = useMutation({
|
||||||
mutationFn: (reason: string) =>
|
mutationFn: (motivo: string) =>
|
||||||
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { reason } }),
|
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { motivo } }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setRejectOpen(false);
|
setRejectOpen(false);
|
||||||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||||||
@@ -295,8 +293,8 @@ export function OrderDetailPage() {
|
|||||||
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
||||||
|
|
||||||
const timeWaiting =
|
const timeWaiting =
|
||||||
order.status === 'pending_approval'
|
order.situa === 1
|
||||||
? Math.floor((Date.now() - new Date(order.issuedAt).getTime()) / 3_600_000)
|
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -304,16 +302,25 @@ export function OrderDetailPage() {
|
|||||||
<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 }}>
|
||||||
{order.number}
|
{order.numPedSar}
|
||||||
</Title>
|
</Title>
|
||||||
<Badge
|
<Badge
|
||||||
status={
|
status={
|
||||||
STATUS_COLOR[order.status] as 'default' | 'warning' | 'processing' | 'success' | 'error'
|
(SITUA_COLOR[order.situa] ?? 'default') as
|
||||||
|
| 'default'
|
||||||
|
| 'warning'
|
||||||
|
| 'processing'
|
||||||
|
| 'success'
|
||||||
|
| 'error'
|
||||||
|
}
|
||||||
|
text={
|
||||||
|
<Tag color={SITUA_COLOR[order.situa] ?? 'default'}>
|
||||||
|
{SITUA_LABEL[order.situa] ?? String(order.situa)}
|
||||||
|
</Tag>
|
||||||
}
|
}
|
||||||
text={<Tag color={STATUS_COLOR[order.status]}>{STATUS_LABEL[order.status]}</Tag>}
|
|
||||||
/>
|
/>
|
||||||
{timeWaiting !== null && timeWaiting > 2 && (
|
{timeWaiting !== null && timeWaiting > 2 && (
|
||||||
<Tag color="red">⏱ Urgente — {timeWaiting}h aguardando</Tag>
|
<Tag color="red">Urgente — {timeWaiting}h aguardando</Tag>
|
||||||
)}
|
)}
|
||||||
{canAct && (
|
{canAct && (
|
||||||
<Space>
|
<Space>
|
||||||
@@ -354,38 +361,43 @@ export function OrderDetailPage() {
|
|||||||
|
|
||||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
|
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
|
||||||
<Descriptions.Item label="Cliente">
|
<Descriptions.Item label="Cliente">
|
||||||
<Link to="/clientes/$id" params={{ id: order.clientId }}>
|
<Link to="/clientes/$id" params={{ id: String(order.idCliente) }}>
|
||||||
{order.clientName}
|
Cód. {order.idCliente}
|
||||||
</Link>
|
</Link>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="Rep">{order.repId}</Descriptions.Item>
|
<Descriptions.Item label="Rep (cód)">{order.codVendedor}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Emitido em">
|
<Descriptions.Item label="Data">
|
||||||
{new Date(order.issuedAt).toLocaleString('pt-BR')}
|
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
{order.approvedAt && (
|
{order.aprovadoEm && (
|
||||||
<Descriptions.Item label="Aprovado em">
|
<Descriptions.Item label="Aprovado em">
|
||||||
{new Date(order.approvedAt).toLocaleString('pt-BR')} — {order.approvedById}
|
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} — cód. {order.aprovadoPor}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
<Descriptions.Item label="Subtotal">{fmt(order.subtotal)}</Descriptions.Item>
|
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Desc. Global">{order.discountPct}%</Descriptions.Item>
|
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
|
||||||
<Descriptions.Item label="Total">
|
<Descriptions.Item label="Total">
|
||||||
<Text strong style={{ fontSize: 16 }}>
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
{fmt(order.total)}
|
{fmt(order.total)}
|
||||||
</Text>
|
</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
{order.notes && (
|
{order.obs && (
|
||||||
<Descriptions.Item label="Observações" span={2}>
|
<Descriptions.Item label="Observações" span={2}>
|
||||||
{order.notes}
|
{order.obs}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{order.motivoRecusa && (
|
||||||
|
<Descriptions.Item label="Motivo Recusa" span={2}>
|
||||||
|
<Text type="danger">{order.motivoRecusa}</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Divider orientation="left">Itens ({order.items.length})</Divider>
|
<Divider orientation="left">Itens ({order.itens.length})</Divider>
|
||||||
<Table<OrderItem>
|
<Table<PedidoItem>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={itemColumns}
|
columns={itemColumns}
|
||||||
dataSource={order.items}
|
dataSource={order.itens}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ marginBottom: 24 }}
|
style={{ marginBottom: 24 }}
|
||||||
@@ -393,7 +405,7 @@ export function OrderDetailPage() {
|
|||||||
|
|
||||||
{clientOrders && clientOrders.length > 0 && (
|
{clientOrders && clientOrders.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider orientation="left">Histórico do Cliente</Divider>
|
<Divider orientation="left">Outros Pedidos do Cliente</Divider>
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -402,7 +414,7 @@ export function OrderDetailPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: 'Nº',
|
title: 'Nº',
|
||||||
dataIndex: 'number',
|
dataIndex: 'numPedSar',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (n: string, r: { id: string }) => (
|
render: (n: string, r: { id: string }) => (
|
||||||
<Link to="/pedidos/$id" params={{ id: r.id }}>
|
<Link to="/pedidos/$id" params={{ id: r.id }}>
|
||||||
@@ -412,9 +424,11 @@ export function OrderDetailPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Status',
|
title: 'Status',
|
||||||
dataIndex: 'status',
|
dataIndex: 'situa',
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (s: OrderStatus) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s]}</Tag>,
|
render: (s: number) => (
|
||||||
|
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total',
|
title: 'Total',
|
||||||
@@ -423,8 +437,8 @@ export function OrderDetailPage() {
|
|||||||
render: (v: string) => fmt(v),
|
render: (v: string) => fmt(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Emitido em',
|
title: 'Data',
|
||||||
dataIndex: 'issuedAt',
|
dataIndex: 'dtPedido',
|
||||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -434,18 +448,18 @@ export function OrderDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider orientation="left">Histórico do Pedido</Divider>
|
<Divider orientation="left">Histórico do Pedido</Divider>
|
||||||
<HistoryTimeline history={order.history} />
|
<HistoryTimeline history={order.historico} />
|
||||||
|
|
||||||
<ApproveModal
|
<ApproveModal
|
||||||
open={approveOpen}
|
open={approveOpen}
|
||||||
originalDiscount={order.discountPct}
|
originalDiscount={order.descontoPerc}
|
||||||
onConfirm={(discountPct, note) => approveMutation.mutate({ discountPct, note })}
|
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })}
|
||||||
onCancel={() => setApproveOpen(false)}
|
onCancel={() => setApproveOpen(false)}
|
||||||
loading={approveMutation.isPending}
|
loading={approveMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<RejectModal
|
<RejectModal
|
||||||
open={rejectOpen}
|
open={rejectOpen}
|
||||||
onConfirm={(reason) => rejectMutation.mutate(reason)}
|
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
|
||||||
onCancel={() => setRejectOpen(false)}
|
onCancel={() => setRejectOpen(false)}
|
||||||
loading={rejectMutation.isPending}
|
loading={rejectMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,52 +2,46 @@ import { useState } from 'react';
|
|||||||
import { Table, Tag, Input, Select, Space, Typography, Badge } from 'antd';
|
import { Table, Tag, Input, Select, Space, Typography, Badge } from 'antd';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import type { OrderSummary, OrderStatus } from '@sar/api-interface';
|
import type { PedidoSummary } from '@sar/api-interface';
|
||||||
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
import { useOrderList } from '../../lib/queries/orders';
|
import { useOrderList } from '../../lib/queries/orders';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
|
|
||||||
const STATUS_COLOR: Record<OrderStatus, string> = {
|
const SITUA_COLOR: Record<number, string> = {
|
||||||
budget: 'default',
|
1: 'warning',
|
||||||
pending_approval: 'warning',
|
2: 'processing',
|
||||||
approved: 'processing',
|
3: 'error',
|
||||||
invoiced: 'success',
|
4: 'success',
|
||||||
cancelled: 'error',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABEL: Record<OrderStatus, string> = {
|
const columns: TableColumnsType<PedidoSummary> = [
|
||||||
budget: 'Orçamento',
|
|
||||||
pending_approval: 'Ag. Aprovação',
|
|
||||||
approved: 'Aprovado',
|
|
||||||
invoiced: 'Faturado',
|
|
||||||
cancelled: 'Cancelado',
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: TableColumnsType<OrderSummary> = [
|
|
||||||
{
|
{
|
||||||
title: 'Nº',
|
title: 'Nº',
|
||||||
dataIndex: 'number',
|
dataIndex: 'numPedSar',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (num: string, row: OrderSummary) => (
|
render: (num: string, row: PedidoSummary) => (
|
||||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
{num}
|
{num}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Cliente',
|
|
||||||
dataIndex: 'clientName',
|
|
||||||
ellipsis: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Status',
|
title: 'Status',
|
||||||
dataIndex: 'status',
|
dataIndex: 'situa',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (s: OrderStatus) => (
|
render: (s: number) => (
|
||||||
<Badge
|
<Badge
|
||||||
status={STATUS_COLOR[s] as 'default' | 'warning' | 'processing' | 'success' | 'error'}
|
status={
|
||||||
text={<Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s]}</Tag>}
|
(SITUA_COLOR[s] ?? 'default') as
|
||||||
|
| 'default'
|
||||||
|
| 'warning'
|
||||||
|
| 'processing'
|
||||||
|
| 'success'
|
||||||
|
| 'error'
|
||||||
|
}
|
||||||
|
text={<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -60,22 +54,22 @@ const columns: TableColumnsType<OrderSummary> = [
|
|||||||
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Emitido em',
|
title: 'Data',
|
||||||
dataIndex: 'issuedAt',
|
dataIndex: 'dtPedido',
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function OrdersPage() {
|
export function OrdersPage() {
|
||||||
const [numberFilter, setNumberFilter] = useState('');
|
const [numFilter, setNumFilter] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>();
|
const [situaFilter, setSituaFilter] = useState<number | undefined>();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
|
|
||||||
const { data, isLoading } = useOrderList({
|
const { data, isLoading } = useOrderList({
|
||||||
number: numberFilter || undefined,
|
numPedSar: numFilter || undefined,
|
||||||
status: statusFilter,
|
situa: situaFilter,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
@@ -88,16 +82,16 @@ export function OrdersPage() {
|
|||||||
|
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
<Search
|
<Search
|
||||||
placeholder="Buscar por número..."
|
placeholder="Buscar por número (SAR-NNNNN)..."
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 220 }}
|
style={{ width: 240 }}
|
||||||
onSearch={(v) => {
|
onSearch={(v) => {
|
||||||
setNumberFilter(v);
|
setNumFilter(v);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (!e.target.value) {
|
if (!e.target.value) {
|
||||||
setNumberFilter('');
|
setNumFilter('');
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -107,25 +101,24 @@ export function OrdersPage() {
|
|||||||
allowClear
|
allowClear
|
||||||
style={{ width: 160 }}
|
style={{ width: 160 }}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setStatusFilter(v as OrderStatus | undefined);
|
setSituaFilter(v as number | undefined);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'budget', label: 'Orçamento' },
|
{ value: 1, label: 'Ag. Aprovação' },
|
||||||
{ value: 'pending_approval', label: 'Ag. Aprovação' },
|
{ value: 2, label: 'Aprovado' },
|
||||||
{ value: 'approved', label: 'Aprovado' },
|
{ value: 3, label: 'Cancelado' },
|
||||||
{ value: 'invoiced', label: 'Faturado' },
|
{ value: 4, label: 'Faturado' },
|
||||||
{ value: 'cancelled', label: 'Cancelado' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Table<OrderSummary>
|
<Table<PedidoSummary>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.data ?? []}
|
dataSource={data?.data ?? []}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
rowClassName={(row) => (row.status === 'pending_approval' ? 'row-pending' : '')}
|
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize: limit,
|
pageSize: limit,
|
||||||
|
|||||||
@@ -6,24 +6,17 @@ import {
|
|||||||
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 { OrderSummary } from '@sar/api-interface';
|
import type { PedidoSummary } from '@sar/api-interface';
|
||||||
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const SITUA_COLOR: Record<number, string> = {
|
||||||
budget: 'Orçamento',
|
1: 'warning',
|
||||||
pending_approval: 'Ag. Aprovação',
|
2: 'processing',
|
||||||
approved: 'Aprovado',
|
3: 'error',
|
||||||
invoiced: 'Faturado',
|
4: 'success',
|
||||||
cancelled: 'Cancelado',
|
|
||||||
};
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
|
||||||
budget: 'default',
|
|
||||||
pending_approval: 'warning',
|
|
||||||
approved: 'processing',
|
|
||||||
invoiced: 'success',
|
|
||||||
cancelled: 'error',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmt(v: number): string {
|
function fmt(v: number): string {
|
||||||
@@ -189,7 +182,7 @@ export function RafaelPainel() {
|
|||||||
<Flex vertical gap={12}>
|
<Flex vertical gap={12}>
|
||||||
{clientesInativos.map((c) => (
|
{clientesInativos.map((c) => (
|
||||||
<Flex
|
<Flex
|
||||||
key={c.id}
|
key={c.idCliente}
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
align="center"
|
align="center"
|
||||||
style={{
|
style={{
|
||||||
@@ -199,8 +192,8 @@ export function RafaelPainel() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size={0}>
|
<Space direction="vertical" size={0}>
|
||||||
<Link to="/clientes/$id" params={{ id: c.id }}>
|
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
|
||||||
<Text strong>{c.name}</Text>
|
<Text strong>{c.nome}</Text>
|
||||||
</Link>
|
</Link>
|
||||||
{c.ultimaCompraValor && (
|
{c.ultimaCompraValor && (
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
@@ -241,16 +234,16 @@ export function RafaelPainel() {
|
|||||||
<Text type="secondary">Nenhum pedido nos últimos 7 dias.</Text>
|
<Text type="secondary">Nenhum pedido nos últimos 7 dias.</Text>
|
||||||
) : (
|
) : (
|
||||||
<Flex vertical gap={10}>
|
<Flex vertical gap={10}>
|
||||||
{pedidosRecentes.map((o: OrderSummary) => (
|
{pedidosRecentes.map((o: PedidoSummary) => (
|
||||||
<Flex key={o.id} justify="space-between" align="center">
|
<Flex key={o.id} justify="space-between" align="center">
|
||||||
<Space direction="vertical" size={0}>
|
<Space direction="vertical" size={0}>
|
||||||
<Link to="/pedidos/$id" params={{ id: o.id }}>
|
<Link to="/pedidos/$id" params={{ id: o.id }}>
|
||||||
<Text strong className="tabular-nums">
|
<Text strong className="tabular-nums">
|
||||||
{o.number}
|
{o.numPedSar}
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
{o.clientName}
|
Cód. cliente {o.idCliente}
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Flex gap={8} align="center">
|
<Flex gap={8} align="center">
|
||||||
@@ -260,7 +253,9 @@ export function RafaelPainel() {
|
|||||||
currency: 'BRL',
|
currency: 'BRL',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Tag color={STATUS_COLOR[o.status]}>{STATUS_LABEL[o.status]}</Tag>
|
<Tag color={SITUA_COLOR[o.situa] ?? 'default'}>
|
||||||
|
{SITUA_LABEL[o.situa] ?? String(o.situa)}
|
||||||
|
</Tag>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { Table, Tag, Typography, Badge, Space } from 'antd';
|
import { Table, Tag, Typography, Badge, Space } from 'antd';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import type { OrderSummary } from '@sar/api-interface';
|
import type { PedidoSummary } from '@sar/api-interface';
|
||||||
import { useOrderList } from '../../lib/queries/orders';
|
import { useOrderList } from '../../lib/queries/orders';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
function hoursWaiting(issuedAt: string): number {
|
function hoursWaiting(createdAt: string): number {
|
||||||
return Math.floor((Date.now() - new Date(issuedAt).getTime()) / 3_600_000);
|
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: TableColumnsType<OrderSummary> = [
|
const columns: TableColumnsType<PedidoSummary> = [
|
||||||
{
|
{
|
||||||
title: 'Nº',
|
title: 'Nº',
|
||||||
dataIndex: 'number',
|
dataIndex: 'numPedSar',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (num: string, row: OrderSummary) => (
|
render: (num: string, row: PedidoSummary) => (
|
||||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
{num}
|
{num}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: 'Rep', dataIndex: 'repId', width: 130, ellipsis: true },
|
{ title: 'Rep (cód)', dataIndex: 'codVendedor', width: 100 },
|
||||||
{ title: 'Cliente', dataIndex: 'clientName', ellipsis: true },
|
{ title: 'Cliente (cód)', dataIndex: 'idCliente', width: 110 },
|
||||||
{
|
{
|
||||||
title: 'Total',
|
title: 'Total',
|
||||||
dataIndex: 'total',
|
dataIndex: 'total',
|
||||||
@@ -32,15 +32,15 @@ const columns: TableColumnsType<OrderSummary> = [
|
|||||||
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Desc. Global',
|
title: 'Desc. %',
|
||||||
dataIndex: 'discountPct',
|
dataIndex: 'descontoPerc',
|
||||||
width: 110,
|
width: 90,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (v: string) => `${v}%`,
|
render: (v: string) => `${v}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Aguardando',
|
title: 'Aguardando',
|
||||||
dataIndex: 'issuedAt',
|
dataIndex: 'createdAt',
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (v: string) => {
|
render: (v: string) => {
|
||||||
const h = hoursWaiting(v);
|
const h = hoursWaiting(v);
|
||||||
@@ -50,7 +50,7 @@ const columns: TableColumnsType<OrderSummary> = [
|
|||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_: unknown, row: OrderSummary) => (
|
render: (_: unknown, row: PedidoSummary) => (
|
||||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
<Tag color="blue" style={{ cursor: 'pointer' }}>
|
<Tag color="blue" style={{ cursor: 'pointer' }}>
|
||||||
Analisar
|
Analisar
|
||||||
@@ -61,9 +61,10 @@ const columns: TableColumnsType<OrderSummary> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function ApprovalQueuePage() {
|
export function ApprovalQueuePage() {
|
||||||
const { data, isLoading } = useOrderList({ status: 'pending_approval', limit: 200 });
|
// situa=1 = Pendente de Aprovação
|
||||||
|
const { data, isLoading } = useOrderList({ situa: 1, limit: 200 });
|
||||||
|
|
||||||
const urgentCount = data?.data.filter((o) => hoursWaiting(o.issuedAt) > 2).length ?? 0;
|
const urgentCount = data?.data.filter((o) => hoursWaiting(o.createdAt) > 2).length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
@@ -80,12 +81,12 @@ export function ApprovalQueuePage() {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Table<OrderSummary>
|
<Table<PedidoSummary>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.data ?? []}
|
dataSource={data?.data ?? []}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
rowClassName={(row) => (hoursWaiting(row.issuedAt) > 2 ? 'row-urgent' : '')}
|
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
|
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
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 { OrderSummary } from '@sar/api-interface';
|
import type { PedidoSummary } from '@sar/api-interface';
|
||||||
import { useSupervisorDashboard } from '../../lib/queries/dashboard';
|
import { useSupervisorDashboard } from '../../lib/queries/dashboard';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
@@ -16,8 +16,8 @@ function fmt(v: number): string {
|
|||||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function hoursWaiting(issuedAt: string): number {
|
function hoursWaiting(createdAt: string): number {
|
||||||
return Math.floor((Date.now() - new Date(issuedAt).getTime()) / 3_600_000);
|
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function delta(current: number, previous: number): { label: string; positive: boolean } | null {
|
function delta(current: number, previous: number): { label: string; positive: boolean } | null {
|
||||||
@@ -30,19 +30,19 @@ function today(): string {
|
|||||||
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
|
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueColumns: TableColumnsType<OrderSummary> = [
|
const queueColumns: TableColumnsType<PedidoSummary> = [
|
||||||
{
|
{
|
||||||
title: 'Pedido',
|
title: 'Pedido',
|
||||||
dataIndex: 'number',
|
dataIndex: 'numPedSar',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (num: string, row: OrderSummary) => (
|
render: (num: string, row: PedidoSummary) => (
|
||||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
{num}
|
{num}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: 'Rep', dataIndex: 'repId', width: 120, ellipsis: true },
|
{ title: 'Rep (cód)', dataIndex: 'codVendedor', width: 100 },
|
||||||
{ title: 'Cliente', dataIndex: 'clientName', ellipsis: true },
|
{ title: 'Cliente (cód)', dataIndex: 'idCliente', width: 110 },
|
||||||
{
|
{
|
||||||
title: 'Total',
|
title: 'Total',
|
||||||
dataIndex: 'total',
|
dataIndex: 'total',
|
||||||
@@ -52,7 +52,7 @@ const queueColumns: TableColumnsType<OrderSummary> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Aguardando',
|
title: 'Aguardando',
|
||||||
dataIndex: 'issuedAt',
|
dataIndex: 'createdAt',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v: string) => {
|
render: (v: string) => {
|
||||||
const h = hoursWaiting(v);
|
const h = hoursWaiting(v);
|
||||||
@@ -62,7 +62,7 @@ const queueColumns: TableColumnsType<OrderSummary> = [
|
|||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (_: unknown, row: OrderSummary) => (
|
render: (_: unknown, row: PedidoSummary) => (
|
||||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
<Tag color="blue" style={{ cursor: 'pointer' }}>
|
<Tag color="blue" style={{ cursor: 'pointer' }}>
|
||||||
Analisar
|
Analisar
|
||||||
@@ -91,7 +91,7 @@ export function SandraPainel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data;
|
const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data;
|
||||||
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.issuedAt) > 2).length;
|
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.createdAt) > 2).length;
|
||||||
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
|
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
|
||||||
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
|
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
|
||||||
|
|
||||||
@@ -211,13 +211,13 @@ export function SandraPainel() {
|
|||||||
}
|
}
|
||||||
extra={<Link to="/aprovacoes">Ver todas</Link>}
|
extra={<Link to="/aprovacoes">Ver todas</Link>}
|
||||||
>
|
>
|
||||||
<Table<OrderSummary>
|
<Table<PedidoSummary>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={queueColumns}
|
columns={queueColumns}
|
||||||
dataSource={approvalQueue.slice(0, 8)}
|
dataSource={approvalQueue.slice(0, 8)}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
rowClassName={(row) => (hoursWaiting(row.issuedAt) > 2 ? 'row-urgent' : '')}
|
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
|
||||||
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
|
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
|
||||||
/>
|
/>
|
||||||
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
|
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
|
||||||
@@ -244,7 +244,7 @@ export function SandraPainel() {
|
|||||||
<Flex vertical gap={12}>
|
<Flex vertical gap={12}>
|
||||||
{inativosPorRep.map((r) => (
|
{inativosPorRep.map((r) => (
|
||||||
<Flex
|
<Flex
|
||||||
key={r.repId}
|
key={r.codVendedor}
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
align="center"
|
align="center"
|
||||||
style={{
|
style={{
|
||||||
@@ -254,7 +254,7 @@ export function SandraPainel() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size={0}>
|
<Space direction="vertical" size={0}>
|
||||||
<Text strong>{r.repId}</Text>
|
<Text strong>Rep cód. {r.codVendedor}</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Tag
|
<Tag
|
||||||
color={r.inativosCount >= 3 ? 'orange' : 'default'}
|
color={r.inativosCount >= 3 ? 'orange' : 'default'}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { AuthTokenResponseSchema } from '@sar/api-interface';
|
|||||||
|
|
||||||
type DevUser = { userId: string; role: string; label: string };
|
type DevUser = { userId: string; role: string; label: string };
|
||||||
|
|
||||||
|
// userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1)
|
||||||
const DEV_USERS: DevUser[] = [
|
const DEV_USERS: DevUser[] = [
|
||||||
{ userId: 'user-001', role: 'rep', label: 'Rafael — Rep (user-001)' },
|
{ userId: '101', role: 'rep', label: 'Rafael — Rep (cod 101)' },
|
||||||
{ userId: 'user-002', role: 'rep', label: 'Rep 2 (user-002)' },
|
{ userId: '102', role: 'rep', label: 'Rep 2 (cod 102)' },
|
||||||
{ userId: 'user-sandra-01', role: 'supervisor', label: 'Sandra — Supervisora' },
|
{ userId: '201', role: 'supervisor', label: 'Sandra — Supervisora (cod 201)' },
|
||||||
{ userId: 'user-manager-01', role: 'manager', label: 'Gerente (user-manager-01)' },
|
{ userId: '301', role: 'manager', label: 'Gerente (cod 301)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
||||||
@@ -26,7 +27,7 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
|||||||
try {
|
try {
|
||||||
const raw = await apiFetch('/auth/dev/token', {
|
const raw = await apiFetch('/auth/dev/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { userId: user.userId, workspaceId: 'dev-workspace', role: user.role },
|
body: { userId: user.userId, idEmpresa: 1, role: user.role },
|
||||||
});
|
});
|
||||||
const { accessToken } = AuthTokenResponseSchema.parse(raw);
|
const { accessToken } = AuthTokenResponseSchema.parse(raw);
|
||||||
authStore.set(accessToken);
|
authStore.set(accessToken);
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ export function FoundationStatus() {
|
|||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<Pill
|
<Pill color={brandTokens.textMuted} label="API…" tooltip="Verificando conexão com a API" />
|
||||||
color={brandTokens.textMuted}
|
|
||||||
label="API…"
|
|
||||||
tooltip="Verificando conexão com a API"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +49,7 @@ export function FoundationStatus() {
|
|||||||
lines={[
|
lines={[
|
||||||
['Service', data.service],
|
['Service', data.service],
|
||||||
['Version', data.version],
|
['Version', data.version],
|
||||||
['Workspace', data.workspaceId],
|
['Empresa', String(data.idEmpresa)],
|
||||||
['Request', data.requestId.slice(0, 8) + '…'],
|
['Request', data.requestId.slice(0, 8) + '…'],
|
||||||
['Uptime', `${data.uptimeSeconds}s`],
|
['Uptime', `${data.uptimeSeconds}s`],
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ProductListResponseSchema,
|
ProdutoListResponseSchema,
|
||||||
type ProductListQuery,
|
type ProdutoListQuery,
|
||||||
type ProductListResponse,
|
type ProdutoListResponse,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { apiFetch } from '../api-client';
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
export function useCatalog(params: Partial<ProductListQuery> = {}) {
|
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);
|
||||||
if (params.category) search.set('category', params.category);
|
if (params.codGrupo) search.set('codGrupo', String(params.codGrupo));
|
||||||
if (params.page) search.set('page', String(params.page));
|
if (params.page) search.set('page', String(params.page));
|
||||||
if (params.limit) search.set('limit', String(params.limit));
|
if (params.limit) search.set('limit', String(params.limit));
|
||||||
|
|
||||||
const qs = search.toString();
|
const qs = search.toString();
|
||||||
return useQuery<ProductListResponse>({
|
return useQuery<ProdutoListResponse>({
|
||||||
queryKey: ['catalog', params],
|
queryKey: ['catalog', params],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiFetch(`/catalog${qs ? `?${qs}` : ''}`);
|
const res = await apiFetch(`/catalog${qs ? `?${qs}` : ''}`);
|
||||||
if (!res.ok) throw new Error(`catalog error ${res.status}`);
|
if (!res.ok) throw new Error(`catalog error ${res.status}`);
|
||||||
return ProductListResponseSchema.parse(await res.json());
|
return ProdutoListResponseSchema.parse(await res.json());
|
||||||
},
|
},
|
||||||
staleTime: 4 * 60 * 60 * 1000, // TTL 4h — FR-4.4
|
staleTime: 4 * 60 * 60 * 1000, // TTL 4h — FR-4.4
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ import { apiFetch } from '../api-client';
|
|||||||
export const CLIENT_KEYS = {
|
export const CLIENT_KEYS = {
|
||||||
all: ['clients'] as const,
|
all: ['clients'] as const,
|
||||||
list: (params: Partial<ClientListQuery>) => ['clients', 'list', params] as const,
|
list: (params: Partial<ClientListQuery>) => ['clients', 'list', params] as const,
|
||||||
detail: (id: string) => ['clients', 'detail', id] as const,
|
detail: (id: number) => ['clients', 'detail', id] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useClientList(params: Partial<ClientListQuery> = {}) {
|
export function useClientList(params: Partial<ClientListQuery> = {}) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.q) qs.set('q', params.q);
|
if (params.q) qs.set('q', params.q);
|
||||||
if (params.status) qs.set('status', params.status);
|
if (params.status) qs.set('status', params.status);
|
||||||
if (params.financialStatus) qs.set('financialStatus', params.financialStatus);
|
|
||||||
if (params.page) qs.set('page', String(params.page));
|
if (params.page) qs.set('page', String(params.page));
|
||||||
if (params.limit) qs.set('limit', String(params.limit));
|
if (params.limit) qs.set('limit', String(params.limit));
|
||||||
|
|
||||||
@@ -27,18 +26,20 @@ export function useClientList(params: Partial<ClientListQuery> = {}) {
|
|||||||
return useQuery<ClientListResponse, Error>({
|
return useQuery<ClientListResponse, Error>({
|
||||||
queryKey: CLIENT_KEYS.list(params),
|
queryKey: CLIENT_KEYS.list(params),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const raw = await apiFetch(`/clients${query ? `?${query}` : ''}`);
|
const res = await apiFetch(`/clients${query ? `?${query}` : ''}`);
|
||||||
return ClientListResponseSchema.parse(raw);
|
if (!res.ok) throw new Error(`clients list error ${res.status}`);
|
||||||
|
return ClientListResponseSchema.parse(await res.json());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useClientDetail(id: string) {
|
export function useClientDetail(id: number | string | undefined) {
|
||||||
return useQuery<ClientDetail, Error>({
|
return useQuery<ClientDetail, Error>({
|
||||||
queryKey: CLIENT_KEYS.detail(id),
|
queryKey: CLIENT_KEYS.detail(Number(id)),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const raw = await apiFetch(`/clients/${id}`);
|
const res = await apiFetch(`/clients/${id}`);
|
||||||
return ClientDetailSchema.parse(raw);
|
if (!res.ok) throw new Error(`client detail error ${res.status}`);
|
||||||
|
return ClientDetailSchema.parse(await res.json());
|
||||||
},
|
},
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,56 +1,56 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
OrderListResponseSchema,
|
PedidoListResponseSchema,
|
||||||
OrderDetailSchema,
|
PedidoDetailSchema,
|
||||||
type OrderListQuery,
|
type PedidoListQuery,
|
||||||
type OrderListResponse,
|
type PedidoListResponse,
|
||||||
type OrderDetail,
|
type PedidoDetail,
|
||||||
type OrderSummary,
|
type PedidoSummary,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { apiFetch } from '../api-client';
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
export function useOrderList(params: Partial<OrderListQuery> = {}) {
|
export function useOrderList(params: Partial<PedidoListQuery> = {}) {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
if (params.clientId) search.set('clientId', params.clientId);
|
if (params.idCliente) search.set('idCliente', String(params.idCliente));
|
||||||
if (params.status) search.set('status', params.status);
|
if (params.situa) search.set('situa', String(params.situa));
|
||||||
if (params.number) search.set('number', params.number);
|
if (params.numPedSar) search.set('numPedSar', params.numPedSar);
|
||||||
if (params.from) search.set('from', params.from);
|
if (params.from) search.set('from', params.from);
|
||||||
if (params.to) search.set('to', params.to);
|
if (params.to) search.set('to', params.to);
|
||||||
if (params.page) search.set('page', String(params.page));
|
if (params.page) search.set('page', String(params.page));
|
||||||
if (params.limit) search.set('limit', String(params.limit));
|
if (params.limit) search.set('limit', String(params.limit));
|
||||||
|
|
||||||
const qs = search.toString();
|
const qs = search.toString();
|
||||||
return useQuery<OrderListResponse>({
|
return useQuery<PedidoListResponse>({
|
||||||
queryKey: ['orders', params],
|
queryKey: ['orders', params],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiFetch(`/orders${qs ? `?${qs}` : ''}`);
|
const res = await apiFetch(`/orders${qs ? `?${qs}` : ''}`);
|
||||||
if (!res.ok) throw new Error(`orders list error ${res.status}`);
|
if (!res.ok) throw new Error(`orders list error ${res.status}`);
|
||||||
return OrderListResponseSchema.parse(await res.json());
|
return PedidoListResponseSchema.parse(await res.json());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOrderDetail(id: string | undefined) {
|
export function useOrderDetail(id: string | undefined) {
|
||||||
return useQuery<OrderDetail>({
|
return useQuery<PedidoDetail>({
|
||||||
queryKey: ['orders', id],
|
queryKey: ['orders', id],
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiFetch(`/orders/${id}`);
|
const res = await apiFetch(`/orders/${id}`);
|
||||||
if (!res.ok) throw new Error(`order detail error ${res.status}`);
|
if (!res.ok) throw new Error(`order detail error ${res.status}`);
|
||||||
return OrderDetailSchema.parse(await res.json());
|
return PedidoDetailSchema.parse(await res.json());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useClientOrders(clientId: string | undefined) {
|
export function useClientOrders(idCliente: number | undefined) {
|
||||||
return useQuery<OrderSummary[]>({
|
return useQuery<PedidoSummary[]>({
|
||||||
queryKey: ['clients', clientId, 'orders'],
|
queryKey: ['clients', idCliente, 'orders'],
|
||||||
enabled: !!clientId,
|
enabled: idCliente != null,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiFetch(`/clients/${clientId}/orders`);
|
const res = await apiFetch(`/orders?idCliente=${idCliente}&limit=10`);
|
||||||
if (!res.ok) throw new Error(`client orders error ${res.status}`);
|
if (!res.ok) throw new Error(`client orders error ${res.status}`);
|
||||||
const data = await res.json();
|
const data = PedidoListResponseSchema.parse(await res.json());
|
||||||
return data as OrderSummary[];
|
return data.data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"fileNames":[],"fileInfos":[],"root":[],"options":{"allowJs":false,"allowSyntheticDefaultImports":true,"composite":false,"declaration":true,"declarationMap":true,"emitDecoratorMetadata":true,"esModuleInterop":false,"experimentalDecorators":true,"jsx":4,"module":199,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noUncheckedIndexedAccess":true,"skipLibCheck":true,"sourceMap":true,"strict":true,"target":10,"useDefineForClassFields":false,"verbatimModuleSyntax":false},"version":"5.9.3"}
|
||||||
@@ -3,12 +3,13 @@ import { z } from 'zod';
|
|||||||
// Contrato do auth dev stub — POST /api/v1/auth/dev/token.
|
// Contrato do auth dev stub — POST /api/v1/auth/dev/token.
|
||||||
// Endpoint existe APENAS em development/test (NODE_ENV !== 'production').
|
// Endpoint existe APENAS em development/test (NODE_ENV !== 'production').
|
||||||
// CODING-RULES PGD-SEC-002: never use dev secret in production.
|
// CODING-RULES PGD-SEC-002: never use dev secret in production.
|
||||||
|
// ADR 0006 revogado: workspaceId: string → idEmpresa: number (empresa no ERP)
|
||||||
|
|
||||||
const JwtRoleSchema = z.enum(['rep', 'supervisor', 'manager', 'admin']);
|
const JwtRoleSchema = z.enum(['rep', 'supervisor', 'manager', 'admin']);
|
||||||
|
|
||||||
export const DevTokenRequestSchema = z.object({
|
export const DevTokenRequestSchema = z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
workspaceId: z.string().min(1),
|
idEmpresa: z.coerce.number().int().positive(),
|
||||||
role: JwtRoleSchema,
|
role: JwtRoleSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,64 +2,53 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
// Contratos canônicos de C2 — Consulta de Clientes.
|
// Contratos canônicos de C2 — Consulta de Clientes.
|
||||||
// Consumidos pela API (output tipado) e pela Web (TanStack Query + parse).
|
// Consumidos pela API (output tipado) e pela Web (TanStack Query + parse).
|
||||||
|
// ADR 0006 revogado: id UUID → idCliente Int (sig.corrent.id_corrent)
|
||||||
|
|
||||||
// ─── Enums ────────────────────────────────────────────────────────────────────
|
// ─── Enums ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const FinancialStatusSchema = z.enum(['regular', 'attention', 'blocked']);
|
|
||||||
export type FinancialStatus = z.infer<typeof FinancialStatusSchema>;
|
|
||||||
|
|
||||||
// Calculado em runtime a partir de lastOrderAt (não persiste no banco).
|
|
||||||
export const ActivityStatusSchema = z.enum(['active', 'alert', 'inactive']);
|
export const ActivityStatusSchema = z.enum(['active', 'alert', 'inactive']);
|
||||||
export type ActivityStatus = z.infer<typeof ActivityStatusSchema>;
|
export type ActivityStatus = z.infer<typeof ActivityStatusSchema>;
|
||||||
|
|
||||||
// ─── Address ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const AddressSchema = z.object({
|
|
||||||
street: z.string().min(1),
|
|
||||||
number: z.string().min(1),
|
|
||||||
complement: z.string().optional(),
|
|
||||||
district: z.string().min(1),
|
|
||||||
city: z.string().min(1),
|
|
||||||
state: z.string().length(2), // UF
|
|
||||||
zip: z.string().regex(/^\d{8}$/), // sem máscara
|
|
||||||
});
|
|
||||||
export type Address = z.infer<typeof AddressSchema>;
|
|
||||||
|
|
||||||
// ─── Client Summary (lista) ───────────────────────────────────────────────────
|
// ─── Client Summary (lista) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export const ClientSummarySchema = z.object({
|
export const ClientSummarySchema = z.object({
|
||||||
id: z.string().uuid(),
|
idCliente: z.number().int(),
|
||||||
name: z.string(),
|
idEmpresa: z.number().int(),
|
||||||
tradeName: z.string().nullable(),
|
nome: z.string(),
|
||||||
taxId: z.string(),
|
razao: z.string().nullable(),
|
||||||
financialStatus: FinancialStatusSchema,
|
cgcpf: z.string().nullable(),
|
||||||
|
email: z.string().nullable(),
|
||||||
|
telefone: z.string().nullable(),
|
||||||
|
codVendedor: z.number().int(),
|
||||||
|
limiteCreditoStr: z.string().nullable(),
|
||||||
activityStatus: ActivityStatusSchema,
|
activityStatus: ActivityStatusSchema,
|
||||||
lastOrderAt: z.iso.datetime().nullable(),
|
dtUltimaCompra: z.iso.datetime().nullable(),
|
||||||
lastOrderValue: z.string().nullable(), // Decimal serializado como string
|
|
||||||
openOrdersCount: z.number().int().nonnegative(),
|
|
||||||
});
|
});
|
||||||
export type ClientSummary = z.infer<typeof ClientSummarySchema>;
|
export type ClientSummary = z.infer<typeof ClientSummarySchema>;
|
||||||
|
|
||||||
// ─── Client Detail (ficha) ───────────────────────────────────────────────────
|
// ─── Client Detail (ficha) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export const ClientDetailSchema = ClientSummarySchema.extend({
|
export const ClientDetailSchema = ClientSummarySchema.extend({
|
||||||
email: z.string().email().nullable(),
|
ativo: z.number().int(),
|
||||||
phone: z.string().nullable(),
|
pessoa: z.number().int().nullable(),
|
||||||
address: AddressSchema.nullable(),
|
inscricaoEstadual: z.string().nullable(),
|
||||||
creditLimit: z.string().nullable(), // Decimal serializado como string; null = não definido
|
endereco: z.string().nullable(),
|
||||||
erpCode: z.string().nullable(),
|
numEndereco: z.string().nullable(),
|
||||||
syncedAt: z.iso.datetime().nullable(),
|
bairro: z.string().nullable(),
|
||||||
createdAt: z.iso.datetime(),
|
cep: z.string().nullable(),
|
||||||
updatedAt: z.iso.datetime(),
|
ddd: z.string().nullable(),
|
||||||
|
obs: z.string().nullable(),
|
||||||
|
codPauta: z.number().int().nullable(),
|
||||||
|
dtCadastro: z.string().nullable(),
|
||||||
|
dtAtual: z.string().nullable(),
|
||||||
});
|
});
|
||||||
export type ClientDetail = z.infer<typeof ClientDetailSchema>;
|
export type ClientDetail = z.infer<typeof ClientDetailSchema>;
|
||||||
|
|
||||||
// ─── List query + response ────────────────────────────────────────────────────
|
// ─── List query + response ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const ClientListQuerySchema = z.object({
|
export const ClientListQuerySchema = z.object({
|
||||||
q: z.string().optional(), // busca nome/taxId
|
q: z.string().optional(),
|
||||||
status: ActivityStatusSchema.optional(), // filtro de atividade
|
status: ActivityStatusSchema.optional(),
|
||||||
financialStatus: FinancialStatusSchema.optional(),
|
|
||||||
page: z.coerce.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { OrderSummarySchema } from './order.contract';
|
import { PedidoSummarySchema } from './order.contract';
|
||||||
|
|
||||||
|
// ADR 0006 revogado: OrderSummary → PedidoSummary, ids numéricos.
|
||||||
|
|
||||||
export const ClienteInativoSchema = z.object({
|
export const ClienteInativoSchema = z.object({
|
||||||
id: z.string().uuid(),
|
idCliente: z.number().int(),
|
||||||
name: z.string(),
|
nome: z.string(),
|
||||||
diasSemCompra: z.number().int(),
|
diasSemCompra: z.number().int(),
|
||||||
ultimaCompraValor: z.string().nullable(),
|
ultimaCompraValor: z.string().nullable(),
|
||||||
});
|
});
|
||||||
@@ -22,20 +24,20 @@ export const RepDashboardSchema = z.object({
|
|||||||
total: z.number(),
|
total: z.number(),
|
||||||
}),
|
}),
|
||||||
pedidosMes: z.number().int(),
|
pedidosMes: z.number().int(),
|
||||||
pedidosRecentes: z.array(OrderSummarySchema),
|
pedidosRecentes: z.array(PedidoSummarySchema),
|
||||||
clientesInativos: z.array(ClienteInativoSchema),
|
clientesInativos: z.array(ClienteInativoSchema),
|
||||||
syncedAt: z.iso.datetime(),
|
syncedAt: z.iso.datetime(),
|
||||||
});
|
});
|
||||||
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
||||||
|
|
||||||
export const RepInativosSummarySchema = z.object({
|
export const RepInativosSummarySchema = z.object({
|
||||||
repId: z.string(),
|
codVendedor: z.number().int(),
|
||||||
inativosCount: z.number().int(),
|
inativosCount: z.number().int(),
|
||||||
});
|
});
|
||||||
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
|
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
|
||||||
|
|
||||||
export const SupervisorDashboardSchema = z.object({
|
export const SupervisorDashboardSchema = z.object({
|
||||||
approvalQueue: z.array(OrderSummarySchema),
|
approvalQueue: z.array(PedidoSummarySchema),
|
||||||
pedidosDia: z.object({
|
pedidosDia: z.object({
|
||||||
count: z.number().int(),
|
count: z.number().int(),
|
||||||
total: z.number(),
|
total: z.number(),
|
||||||
|
|||||||
@@ -1,128 +1,137 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Contratos canônicos de C3 — Consulta de Pedidos.
|
// Contratos canônicos de C3 — Pedidos SAR.
|
||||||
// Consumidos pela API (output tipado) e pela Web (TanStack Query + parse).
|
// Consumidos pela API (output tipado) e pela Web (TanStack Query + parse).
|
||||||
|
// ADR 0006 revogado: OrderStatus enum → situa Int (1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado)
|
||||||
|
|
||||||
// ─── Enums ────────────────────────────────────────────────────────────────────
|
// ─── Situa ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const OrderStatusSchema = z.enum([
|
// situa: 1=Pendente 2=Aprovado 3=Cancelado 4=Faturado
|
||||||
'budget',
|
export const SituaPedidoSchema = z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]);
|
||||||
'pending_approval',
|
export type SituaPedido = z.infer<typeof SituaPedidoSchema>;
|
||||||
'approved',
|
|
||||||
'invoiced',
|
|
||||||
'cancelled',
|
|
||||||
]);
|
|
||||||
export type OrderStatus = z.infer<typeof OrderStatusSchema>;
|
|
||||||
|
|
||||||
// ─── OrderItem ────────────────────────────────────────────────────────────────
|
export const SITUA_LABEL: Record<number, string> = {
|
||||||
|
1: 'Ag. Aprovação',
|
||||||
|
2: 'Aprovado',
|
||||||
|
3: 'Cancelado',
|
||||||
|
4: 'Faturado',
|
||||||
|
};
|
||||||
|
|
||||||
export const OrderItemSchema = z.object({
|
// ─── PedidoItem ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PedidoItemSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
productCode: z.string(),
|
idProduto: z.number().int(),
|
||||||
productName: z.string(),
|
codProduto: z.string().nullable(),
|
||||||
quantity: z.string(), // Decimal serializado
|
descProduto: z.string().nullable(),
|
||||||
unitPrice: z.string(), // Decimal serializado
|
ordem: z.number().int(),
|
||||||
discountPct: z.string(), // Decimal serializado
|
qtd: z.string(),
|
||||||
subtotal: z.string(), // Decimal serializado
|
precoUnitario: z.string(),
|
||||||
|
descontoPerc: z.string(),
|
||||||
|
total: z.string(),
|
||||||
});
|
});
|
||||||
export type OrderItem = z.infer<typeof OrderItemSchema>;
|
export type PedidoItem = z.infer<typeof PedidoItemSchema>;
|
||||||
|
|
||||||
// ─── OrderStatusHistory ───────────────────────────────────────────────────────
|
// ─── HistoricoPedido ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const OrderStatusHistorySchema = z.object({
|
export const HistoricoPedidoSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
fromStatus: OrderStatusSchema.nullable(),
|
situaAnterior: z.number().int().nullable(),
|
||||||
toStatus: OrderStatusSchema,
|
situaNova: z.number().int(),
|
||||||
changedById: z.string(),
|
changedBy: z.number().int(),
|
||||||
note: z.string().nullable(),
|
nota: z.string().nullable(),
|
||||||
changedAt: z.iso.datetime(),
|
changedAt: z.iso.datetime(),
|
||||||
});
|
});
|
||||||
export type OrderStatusHistory = z.infer<typeof OrderStatusHistorySchema>;
|
export type HistoricoPedido = z.infer<typeof HistoricoPedidoSchema>;
|
||||||
|
|
||||||
// ─── Order Summary (lista) ────────────────────────────────────────────────────
|
// ─── Pedido Summary (lista) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export const OrderSummarySchema = z.object({
|
export const PedidoSummarySchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
number: z.string(),
|
numPedSar: z.string(),
|
||||||
clientId: z.string().uuid(),
|
idCliente: z.number().int(),
|
||||||
clientName: z.string(),
|
codVendedor: z.number().int(),
|
||||||
repId: z.string(),
|
situa: z.number().int(),
|
||||||
status: OrderStatusSchema,
|
dtPedido: z.string(),
|
||||||
discountPct: z.string(),
|
|
||||||
subtotal: z.string(),
|
|
||||||
total: z.string(),
|
total: z.string(),
|
||||||
issuedAt: z.iso.datetime(),
|
descontoPerc: z.string(),
|
||||||
approvedAt: z.iso.datetime().nullable(),
|
obs: z.string().nullable(),
|
||||||
invoicedAt: z.iso.datetime().nullable(),
|
|
||||||
cancelledAt: z.iso.datetime().nullable(),
|
|
||||||
});
|
|
||||||
export type OrderSummary = z.infer<typeof OrderSummarySchema>;
|
|
||||||
|
|
||||||
// ─── Order Detail ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const OrderDetailSchema = OrderSummarySchema.extend({
|
|
||||||
notes: z.string().nullable(),
|
|
||||||
approvedById: z.string().nullable(),
|
|
||||||
idempotencyKey: z.string().nullable(),
|
|
||||||
createdAt: z.iso.datetime(),
|
createdAt: z.iso.datetime(),
|
||||||
updatedAt: z.iso.datetime(),
|
|
||||||
items: z.array(OrderItemSchema),
|
|
||||||
history: z.array(OrderStatusHistorySchema),
|
|
||||||
});
|
});
|
||||||
export type OrderDetail = z.infer<typeof OrderDetailSchema>;
|
export type PedidoSummary = z.infer<typeof PedidoSummarySchema>;
|
||||||
|
|
||||||
|
// ─── Pedido Detail ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PedidoDetailSchema = PedidoSummarySchema.extend({
|
||||||
|
totalProdutos: z.string(),
|
||||||
|
totalIpi: z.string(),
|
||||||
|
totalIcmsst: z.string(),
|
||||||
|
descontoValor: z.string(),
|
||||||
|
acrescimo: z.string(),
|
||||||
|
comissao: z.string(),
|
||||||
|
pedFlex: z.string(),
|
||||||
|
aprovadoPor: z.number().int().nullable(),
|
||||||
|
aprovadoEm: z.iso.datetime().nullable(),
|
||||||
|
motivoRecusa: z.string().nullable(),
|
||||||
|
idempotencyKey: z.string().nullable(),
|
||||||
|
updatedAt: z.iso.datetime(),
|
||||||
|
itens: z.array(PedidoItemSchema),
|
||||||
|
historico: z.array(HistoricoPedidoSchema),
|
||||||
|
});
|
||||||
|
export type PedidoDetail = z.infer<typeof PedidoDetailSchema>;
|
||||||
|
|
||||||
// ─── List query + response ────────────────────────────────────────────────────
|
// ─── List query + response ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const OrderListQuerySchema = z.object({
|
export const PedidoListQuerySchema = z.object({
|
||||||
clientId: z.string().uuid().optional(),
|
idCliente: z.coerce.number().int().optional(),
|
||||||
status: OrderStatusSchema.optional(),
|
situa: z.coerce.number().int().optional(),
|
||||||
number: z.string().optional(), // busca parcial por número
|
numPedSar: z.string().optional(),
|
||||||
from: z.iso.datetime().optional(), // issuedAt >= from
|
from: z.string().optional(),
|
||||||
to: z.iso.datetime().optional(), // issuedAt <= to
|
to: z.string().optional(),
|
||||||
page: z.coerce.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
});
|
});
|
||||||
export type OrderListQuery = z.infer<typeof OrderListQuerySchema>;
|
export type PedidoListQuery = z.infer<typeof PedidoListQuerySchema>;
|
||||||
|
|
||||||
export const OrderListResponseSchema = z.object({
|
export const PedidoListResponseSchema = z.object({
|
||||||
data: z.array(OrderSummarySchema),
|
data: z.array(PedidoSummarySchema),
|
||||||
total: z.number().int().nonnegative(),
|
total: z.number().int().nonnegative(),
|
||||||
page: z.number().int().positive(),
|
page: z.number().int().positive(),
|
||||||
limit: z.number().int().positive(),
|
limit: z.number().int().positive(),
|
||||||
});
|
});
|
||||||
export type OrderListResponse = z.infer<typeof OrderListResponseSchema>;
|
export type PedidoListResponse = z.infer<typeof PedidoListResponseSchema>;
|
||||||
|
|
||||||
// ─── Create Order (POST /orders) ──────────────────────────────────────────────
|
// ─── Mutações ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const CreateOrderItemSchema = z.object({
|
export const CreatePedidoItemSchema = z.object({
|
||||||
productCode: z.string().min(1),
|
idProduto: z.number().int().positive(),
|
||||||
productName: z.string().min(1),
|
codProduto: z.string().optional(),
|
||||||
productCategory: z.string().default('geral'),
|
descProduto: z.string().min(1),
|
||||||
quantity: z.number().positive(),
|
ordem: z.number().int().min(1),
|
||||||
unitPrice: z.number().positive(),
|
qtd: z.number().positive(),
|
||||||
discountPct: z.number().min(0).max(100).default(0),
|
precoUnitario: z.number().nonnegative(),
|
||||||
|
descontoPerc: z.number().min(0).max(100).default(0),
|
||||||
});
|
});
|
||||||
export type CreateOrderItem = z.infer<typeof CreateOrderItemSchema>;
|
export type CreatePedidoItem = z.infer<typeof CreatePedidoItemSchema>;
|
||||||
|
|
||||||
export const CreateOrderSchema = z.object({
|
export const CreatePedidoSchema = z.object({
|
||||||
clientId: z.string().uuid(),
|
idCliente: z.number().int().positive(),
|
||||||
discountPct: z.number().min(0).max(100).default(0), // desconto global do pedido
|
descontoPerc: z.number().min(0).max(100).default(0),
|
||||||
notes: z.string().optional(),
|
idPauta: z.number().int().optional(),
|
||||||
|
codFormapag: z.number().int().optional(),
|
||||||
|
obs: z.string().optional(),
|
||||||
idempotencyKey: z.string().optional(),
|
idempotencyKey: z.string().optional(),
|
||||||
items: z.array(CreateOrderItemSchema).min(1),
|
itens: z.array(CreatePedidoItemSchema).min(1),
|
||||||
});
|
});
|
||||||
export type CreateOrder = z.infer<typeof CreateOrderSchema>;
|
export type CreatePedido = z.infer<typeof CreatePedidoSchema>;
|
||||||
|
|
||||||
// ─── Approve / Reject (PATCH /orders/:id/approve|reject) ─────────────────────
|
export const AprovarPedidoSchema = z.object({
|
||||||
|
descontoPerc: z.number().min(0).max(100).optional(),
|
||||||
export const ApproveOrderSchema = z.object({
|
nota: z.string().optional(),
|
||||||
// Opcional — supervisor pode ajustar o desconto global. Se omitido, mantém o original.
|
|
||||||
discountPct: z.number().min(0).max(100).optional(),
|
|
||||||
note: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
export type ApproveOrder = z.infer<typeof ApproveOrderSchema>;
|
export type AprovarPedido = z.infer<typeof AprovarPedidoSchema>;
|
||||||
|
|
||||||
export const RejectOrderSchema = z.object({
|
export const RecusarPedidoSchema = z.object({
|
||||||
reason: z.string().min(1, 'Motivo é obrigatório'), // FR-5.4
|
motivo: z.string().min(1),
|
||||||
});
|
});
|
||||||
export type RejectOrder = z.infer<typeof RejectOrderSchema>;
|
export type RecusarPedido = z.infer<typeof RecusarPedidoSchema>;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const PingResponseSchema = z.object({
|
|||||||
status: z.literal('ok'),
|
status: z.literal('ok'),
|
||||||
service: z.string().min(1),
|
service: z.string().min(1),
|
||||||
version: z.string().min(1),
|
version: z.string().min(1),
|
||||||
workspaceId: z.string().min(1),
|
idEmpresa: z.number().int(),
|
||||||
requestId: z.uuid(),
|
requestId: z.uuid(),
|
||||||
uptimeSeconds: z.number().int().nonnegative(),
|
uptimeSeconds: z.number().int().nonnegative(),
|
||||||
now: z.iso.datetime(),
|
now: z.iso.datetime(),
|
||||||
|
|||||||
@@ -2,70 +2,57 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
// Contratos canônicos de C4 — Catálogo de Produtos.
|
// Contratos canônicos de C4 — Catálogo de Produtos.
|
||||||
// Consumidos pela API (output tipado) e pela Web (TanStack Query + parse).
|
// Consumidos pela API (output tipado) e pela Web (TanStack Query + parse).
|
||||||
|
// ADR 0006 revogado: produto lido diretamente de vw_produtos (ERP), sem sync.
|
||||||
|
|
||||||
// ─── Product Summary (lista) ──────────────────────────────────────────────────
|
// ─── Produto Summary (lista) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export const ProductSummarySchema = z.object({
|
export const ProdutoSummarySchema = z.object({
|
||||||
id: z.string().uuid(),
|
idErp: z.number().int(),
|
||||||
code: z.string(),
|
codigo: z.string(),
|
||||||
name: z.string(),
|
descricao: z.string(),
|
||||||
category: z.string(),
|
unidade: z.string().nullable(),
|
||||||
unitPrice: z.string(), // Decimal serializado
|
vlPreco1: z.string(),
|
||||||
stock: z.string().nullable(),
|
codGrupo: z.number().int().nullable(),
|
||||||
active: z.boolean(),
|
grupo: z.string().nullable(),
|
||||||
|
codSubgrupo: z.number().int().nullable(),
|
||||||
|
subgrupo: z.string().nullable(),
|
||||||
|
marca: z.string().nullable(),
|
||||||
|
ativo: z.number().int(),
|
||||||
|
qtdEstoque: z.string().nullable(),
|
||||||
|
listaParauta: z.number().int().nullable(),
|
||||||
});
|
});
|
||||||
export type ProductSummary = z.infer<typeof ProductSummarySchema>;
|
export type ProdutoSummary = z.infer<typeof ProdutoSummarySchema>;
|
||||||
|
|
||||||
// ─── Product Detail ───────────────────────────────────────────────────────────
|
// ─── Produto Detail ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const ProductDetailSchema = ProductSummarySchema.extend({
|
export const ProdutoDetailSchema = ProdutoSummarySchema.extend({
|
||||||
description: z.string().nullable(),
|
referencia: z.string().nullable(),
|
||||||
erpCode: z.string().nullable(),
|
descricaoDetalhada: z.string().nullable(),
|
||||||
syncedAt: z.iso.datetime().nullable(),
|
vlPreco2: z.string().nullable(),
|
||||||
createdAt: z.iso.datetime(),
|
vlPreco3: z.string().nullable(),
|
||||||
updatedAt: z.iso.datetime(),
|
aliqIpi: z.string().nullable(),
|
||||||
|
pesoLiquido: z.string().nullable(),
|
||||||
|
qtdVolume: z.string().nullable(),
|
||||||
|
loteMulVenda: z.number().int().nullable(),
|
||||||
|
precoComIpi: z.string().nullable(),
|
||||||
|
precoPromocional: z.string().nullable(),
|
||||||
});
|
});
|
||||||
export type ProductDetail = z.infer<typeof ProductDetailSchema>;
|
export type ProdutoDetail = z.infer<typeof ProdutoDetailSchema>;
|
||||||
|
|
||||||
// ─── List query + response ────────────────────────────────────────────────────
|
// ─── List query + response ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const ProductListQuerySchema = z.object({
|
export const ProdutoListQuerySchema = z.object({
|
||||||
q: z.string().optional(), // busca nome/código
|
q: z.string().optional(),
|
||||||
category: z.string().optional(), // filtra por categoria
|
codGrupo: z.coerce.number().int().optional(),
|
||||||
page: z.coerce.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
});
|
});
|
||||||
export type ProductListQuery = z.infer<typeof ProductListQuerySchema>;
|
export type ProdutoListQuery = z.infer<typeof ProdutoListQuerySchema>;
|
||||||
|
|
||||||
export const ProductListResponseSchema = z.object({
|
export const ProdutoListResponseSchema = z.object({
|
||||||
data: z.array(ProductSummarySchema),
|
data: z.array(ProdutoSummarySchema),
|
||||||
total: z.number().int().nonnegative(),
|
total: z.number().int().nonnegative(),
|
||||||
page: z.number().int().positive(),
|
page: z.number().int().positive(),
|
||||||
limit: z.number().int().positive(),
|
limit: z.number().int().positive(),
|
||||||
});
|
});
|
||||||
export type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
|
export type ProdutoListResponse = z.infer<typeof ProdutoListResponseSchema>;
|
||||||
|
|
||||||
// ─── Sync (importação da view do ERP) ────────────────────────────────────────
|
|
||||||
|
|
||||||
export const ProductSyncItemSchema = z.object({
|
|
||||||
code: z.string().min(1),
|
|
||||||
name: z.string().min(1),
|
|
||||||
description: z.string().optional(),
|
|
||||||
category: z.string().default('geral'),
|
|
||||||
unitPrice: z.number().positive(),
|
|
||||||
stock: z.number().nonnegative().optional(),
|
|
||||||
active: z.boolean().default(true),
|
|
||||||
erpCode: z.string().optional(),
|
|
||||||
});
|
|
||||||
export type ProductSyncItem = z.infer<typeof ProductSyncItemSchema>;
|
|
||||||
|
|
||||||
export const ProductSyncRequestSchema = z.object({
|
|
||||||
items: z.array(ProductSyncItemSchema).min(1).max(5000),
|
|
||||||
});
|
|
||||||
export type ProductSyncRequest = z.infer<typeof ProductSyncRequestSchema>;
|
|
||||||
|
|
||||||
export const ProductSyncResponseSchema = z.object({
|
|
||||||
upserted: z.number().int().nonnegative(),
|
|
||||||
syncedAt: z.iso.datetime(),
|
|
||||||
});
|
|
||||||
export type ProductSyncResponse = z.infer<typeof ProductSyncResponseSchema>;
|
|
||||||
|
|||||||
1
libs/shared/api-interface/tsconfig.tsbuildinfo
Normal file
1
libs/shared/api-interface/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"fileNames":[],"fileInfos":[],"root":[],"options":{"allowSyntheticDefaultImports":true,"composite":false,"declaration":true,"declarationMap":true,"emitDecoratorMetadata":true,"esModuleInterop":true,"experimentalDecorators":true,"importHelpers":true,"module":199,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noImplicitReturns":true,"noPropertyAccessFromIndexSignature":true,"noUncheckedIndexedAccess":true,"skipLibCheck":true,"sourceMap":true,"strict":true,"target":10,"useDefineForClassFields":false,"verbatimModuleSyntax":false},"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user