feat(c4): lançamento de pedido — catálogo, alçada por linha, POST /orders
- Prisma: Product + RepDiscountLimit + productCategory em OrderItem + migration - Seed: 28 produtos (5 categorias) + alçadas user-001 (default 10%, bebidas 8%, perecíveis 5%) - @sar/api-interface: ProductSummarySchema, ProductDetailSchema, ProductSyncRequestSchema, CreateOrderSchema - API: CatalogModule (GET /catalog, GET /catalog/:id, POST /catalog/sync) - API: POST /orders — valida alçada por linha/produto (OQ-2), idempotency-key (FR-4.3), desnorm cliente - Web: NewOrderPage (3 steps: catálogo → desconto/obs → confirmação) - Web: botão Novo Pedido na ClientDetailPage (desabilitado se financialStatus=blocked) - Web: rota /pedidos/novo com search param clientId Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "OrderItem" ADD COLUMN "productCategory" TEXT NOT NULL DEFAULT 'geral';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Product" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"category" TEXT NOT NULL DEFAULT 'geral',
|
||||||
|
"unitPrice" DECIMAL(15,2) NOT NULL,
|
||||||
|
"stock" DECIMAL(10,3),
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"erpCode" TEXT,
|
||||||
|
"syncedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"deletedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RepDiscountLimit" (
|
||||||
|
"repId" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"limit" DECIMAL(5,2) NOT NULL DEFAULT 5,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RepDiscountLimit_pkey" PRIMARY KEY ("repId","category")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Product_code_key" ON "Product"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Product_code_idx" ON "Product"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Product_name_idx" ON "Product"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Product_category_idx" ON "Product"("category");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Product_active_idx" ON "Product"("active");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Product_deletedAt_idx" ON "Product"("deletedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RepDiscountLimit_repId_idx" ON "RepDiscountLimit"("repId");
|
||||||
@@ -119,9 +119,10 @@ model Order {
|
|||||||
@@index([deletedAt])
|
@@index([deletedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── OrderItem (C3) ──────────────────────────────────────────────────────────
|
// ─── OrderItem (C3/C4) ───────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Item do pedido. Produto desnormalizado (nome/código como string) — catálogo virá em C4.
|
// Item do pedido. Produto desnormalizado (nome/código/categoria) — snapshot no momento do pedido.
|
||||||
|
// productCategory: usado para validação de alçada por linha no POST /orders.
|
||||||
// discountPct: desconto por linha (além do desconto global do Order).
|
// discountPct: desconto por linha (além do desconto global do Order).
|
||||||
|
|
||||||
model OrderItem {
|
model OrderItem {
|
||||||
@@ -129,6 +130,7 @@ model OrderItem {
|
|||||||
orderId String @db.Uuid
|
orderId String @db.Uuid
|
||||||
productCode String // código no ERP / catálogo
|
productCode String // código no ERP / catálogo
|
||||||
productName String // desnormalizado para exibição offline
|
productName String // desnormalizado para exibição offline
|
||||||
|
productCategory String @default("geral") // desnormalizado para alçada por linha
|
||||||
quantity Decimal @db.Decimal(10, 3)
|
quantity Decimal @db.Decimal(10, 3)
|
||||||
unitPrice Decimal @db.Decimal(15, 2)
|
unitPrice Decimal @db.Decimal(15, 2)
|
||||||
discountPct Decimal @default(0) @db.Decimal(5, 2)
|
discountPct Decimal @default(0) @db.Decimal(5, 2)
|
||||||
@@ -139,6 +141,53 @@ model OrderItem {
|
|||||||
@@index([orderId])
|
@@index([orderId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Product (C4) ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Catálogo sincronizado da view do ERP (FR-4.4). Rep usa para montar pedido.
|
||||||
|
// category: agrupa produtos por linha para validação de alçada por linha (OQ-2).
|
||||||
|
// 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 {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
category String @default("geral")
|
||||||
|
unitPrice Decimal @db.Decimal(15, 2)
|
||||||
|
stock Decimal? @db.Decimal(10, 3)
|
||||||
|
active Boolean @default(true)
|
||||||
|
erpCode String?
|
||||||
|
syncedAt DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
@@index([code])
|
||||||
|
@@index([name])
|
||||||
|
@@index([category])
|
||||||
|
@@index([active])
|
||||||
|
@@index([deletedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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])
|
||||||
|
}
|
||||||
|
|
||||||
// ─── OrderStatusHistory (C3) ─────────────────────────────────────────────────
|
// ─── OrderStatusHistory (C3) ─────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Registro imutável de cada transição de status. changedById = userId do ator.
|
// Registro imutável de cada transição de status. changedById = userId do ator.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Executado via: pnpm exec prisma db seed (apps/api/)
|
// Executado via: pnpm exec prisma db seed (apps/api/)
|
||||||
// NUNCA rodar em staging/prod.
|
// NUNCA rodar em staging/prod.
|
||||||
|
|
||||||
import { PrismaClient, FinancialStatus, OrderStatus } from '@prisma/client';
|
import { PrismaClient, FinancialStatus, OrderStatus, type Prisma } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
|
|
||||||
@@ -226,6 +226,253 @@ const clientDefs = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── Catálogo de produtos (25 produtos, 5 categorias) ────────────────────────
|
||||||
|
|
||||||
|
const products: Prisma.ProductCreateInput[] = [
|
||||||
|
// grãos
|
||||||
|
{
|
||||||
|
code: 'ARR-001',
|
||||||
|
name: 'Arroz Tipo 1 5kg cx10',
|
||||||
|
category: 'grãos',
|
||||||
|
unitPrice: 75.0,
|
||||||
|
stock: 500,
|
||||||
|
erpCode: 'P0001',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'FEI-001',
|
||||||
|
name: 'Feijão Carioca 1kg cx10',
|
||||||
|
category: 'grãos',
|
||||||
|
unitPrice: 65.0,
|
||||||
|
stock: 400,
|
||||||
|
erpCode: 'P0002',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'FAR-001',
|
||||||
|
name: 'Farinha de Trigo 25kg',
|
||||||
|
category: 'grãos',
|
||||||
|
unitPrice: 89.9,
|
||||||
|
stock: 200,
|
||||||
|
erpCode: 'P0003',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ACU-001',
|
||||||
|
name: 'Açúcar Cristal 50kg',
|
||||||
|
category: 'grãos',
|
||||||
|
unitPrice: 145.0,
|
||||||
|
stock: 150,
|
||||||
|
erpCode: 'P0004',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'MIL-001',
|
||||||
|
name: 'Flocão de Milho 500g cx20',
|
||||||
|
category: 'grãos',
|
||||||
|
unitPrice: 48.0,
|
||||||
|
stock: 300,
|
||||||
|
erpCode: 'P0005',
|
||||||
|
},
|
||||||
|
// bebidas
|
||||||
|
{
|
||||||
|
code: 'OLE-001',
|
||||||
|
name: 'Óleo de Soja 900ml cx18',
|
||||||
|
category: 'bebidas',
|
||||||
|
unitPrice: 98.0,
|
||||||
|
stock: 600,
|
||||||
|
erpCode: 'P0006',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'REF-001',
|
||||||
|
name: 'Refrigerante 2L cx6',
|
||||||
|
category: 'bebidas',
|
||||||
|
unitPrice: 42.0,
|
||||||
|
stock: 800,
|
||||||
|
erpCode: 'P0007',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'AGU-001',
|
||||||
|
name: 'Água Mineral 500ml cx12',
|
||||||
|
category: 'bebidas',
|
||||||
|
unitPrice: 18.0,
|
||||||
|
stock: 1000,
|
||||||
|
erpCode: 'P0008',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'SUC-001',
|
||||||
|
name: 'Suco de Caixinha 200ml cx27',
|
||||||
|
category: 'bebidas',
|
||||||
|
unitPrice: 32.4,
|
||||||
|
stock: 700,
|
||||||
|
erpCode: 'P0009',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'CER-001',
|
||||||
|
name: 'Cerveja Lata 350ml cx12',
|
||||||
|
category: 'bebidas',
|
||||||
|
unitPrice: 52.8,
|
||||||
|
stock: 400,
|
||||||
|
erpCode: 'P0010',
|
||||||
|
},
|
||||||
|
// laticínios
|
||||||
|
{
|
||||||
|
code: 'LEI-001',
|
||||||
|
name: 'Leite UHT Integral 1L cx12',
|
||||||
|
category: 'laticínios',
|
||||||
|
unitPrice: 54.0,
|
||||||
|
stock: 900,
|
||||||
|
erpCode: 'P0011',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'QUE-001',
|
||||||
|
name: 'Queijo Mussarela kg',
|
||||||
|
category: 'laticínios',
|
||||||
|
unitPrice: 38.0,
|
||||||
|
stock: 200,
|
||||||
|
erpCode: 'P0012',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'IOG-001',
|
||||||
|
name: 'Iogurte Natural 170g cx12',
|
||||||
|
category: 'laticínios',
|
||||||
|
unitPrice: 28.8,
|
||||||
|
stock: 350,
|
||||||
|
erpCode: 'P0013',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'MAN-001',
|
||||||
|
name: 'Manteiga com Sal 200g cx12',
|
||||||
|
category: 'laticínios',
|
||||||
|
unitPrice: 84.0,
|
||||||
|
stock: 250,
|
||||||
|
erpCode: 'P0014',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'REQ-001',
|
||||||
|
name: 'Requeijão Cremoso 200g cx12',
|
||||||
|
category: 'laticínios',
|
||||||
|
unitPrice: 72.0,
|
||||||
|
stock: 180,
|
||||||
|
erpCode: 'P0015',
|
||||||
|
},
|
||||||
|
// perecíveis
|
||||||
|
{
|
||||||
|
code: 'CAR-001',
|
||||||
|
name: 'Carne Bovina Contrafilé kg',
|
||||||
|
category: 'perecíveis',
|
||||||
|
unitPrice: 65.0,
|
||||||
|
stock: 150,
|
||||||
|
erpCode: 'P0016',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'FRA-001',
|
||||||
|
name: 'Frango Inteiro Resfriado kg',
|
||||||
|
category: 'perecíveis',
|
||||||
|
unitPrice: 18.5,
|
||||||
|
stock: 400,
|
||||||
|
erpCode: 'P0017',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'PEI-001',
|
||||||
|
name: 'Peixe Tilápia Filé kg',
|
||||||
|
category: 'perecíveis',
|
||||||
|
unitPrice: 32.0,
|
||||||
|
stock: 100,
|
||||||
|
erpCode: 'P0018',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EMB-001',
|
||||||
|
name: 'Embutidos Sortidos kg',
|
||||||
|
category: 'perecíveis',
|
||||||
|
unitPrice: 28.0,
|
||||||
|
stock: 300,
|
||||||
|
erpCode: 'P0019',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'LEG-001',
|
||||||
|
name: 'Legumes Sortidos kg',
|
||||||
|
category: 'perecíveis',
|
||||||
|
unitPrice: 8.5,
|
||||||
|
stock: 500,
|
||||||
|
erpCode: 'P0020',
|
||||||
|
},
|
||||||
|
// higiene
|
||||||
|
{
|
||||||
|
code: 'SAB-001',
|
||||||
|
name: 'Sabão em Pó 1kg cx12',
|
||||||
|
category: 'higiene',
|
||||||
|
unitPrice: 42.0,
|
||||||
|
stock: 400,
|
||||||
|
erpCode: 'P0021',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'DET-001',
|
||||||
|
name: 'Detergente 500ml cx24',
|
||||||
|
category: 'higiene',
|
||||||
|
unitPrice: 38.4,
|
||||||
|
stock: 600,
|
||||||
|
erpCode: 'P0022',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'HIG-001',
|
||||||
|
name: 'Shampoo 400ml cx12',
|
||||||
|
category: 'higiene',
|
||||||
|
unitPrice: 96.0,
|
||||||
|
stock: 250,
|
||||||
|
erpCode: 'P0023',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'LEV-001',
|
||||||
|
name: 'Fermento Biológico 500g',
|
||||||
|
category: 'grãos',
|
||||||
|
unitPrice: 12.5,
|
||||||
|
stock: 200,
|
||||||
|
erpCode: 'P0024',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'CON-001',
|
||||||
|
name: 'Conservas Sortidas cx24',
|
||||||
|
category: 'grãos',
|
||||||
|
unitPrice: 96.0,
|
||||||
|
stock: 180,
|
||||||
|
erpCode: 'P0025',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'MOL-001',
|
||||||
|
name: 'Molho de Tomate 340g cx24',
|
||||||
|
category: 'grãos',
|
||||||
|
unitPrice: 72.0,
|
||||||
|
stock: 220,
|
||||||
|
erpCode: 'P0026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'FRU-001',
|
||||||
|
name: 'Frutas Sortidas cx',
|
||||||
|
category: 'perecíveis',
|
||||||
|
unitPrice: 45.0,
|
||||||
|
stock: 80,
|
||||||
|
erpCode: 'P0027',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'VER-001',
|
||||||
|
name: 'Verduras Sortidas kg',
|
||||||
|
category: 'perecíveis',
|
||||||
|
unitPrice: 6.0,
|
||||||
|
stock: 300,
|
||||||
|
erpCode: 'P0028',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mapa code → category para popular productCategory nos OrderItems do seed C3
|
||||||
|
const productCategoryMap: Record<string, string> = Object.fromEntries(
|
||||||
|
products.map((p) => [p.code, p.category ?? 'geral']),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alçadas de desconto de user-001: default 10%, bebidas 8%, perecíveis 5%
|
||||||
|
const repDiscountLimits = [
|
||||||
|
{ repId: DEV_REP_ID, category: '__default__', limit: 10 },
|
||||||
|
{ repId: DEV_REP_ID, category: 'bebidas', limit: 8 },
|
||||||
|
{ repId: DEV_REP_ID, category: 'perecíveis', limit: 5 },
|
||||||
|
{ repId: DEV_REP2_ID, category: '__default__', limit: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
type OrderSeed = {
|
type OrderSeed = {
|
||||||
num: number;
|
num: number;
|
||||||
status: OrderStatus;
|
status: OrderStatus;
|
||||||
@@ -721,6 +968,26 @@ function buildHistoryForStatus(status: OrderStatus, repId: string, issuedDaysAgo
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log('Seed iniciado...');
|
console.log('Seed iniciado...');
|
||||||
|
|
||||||
|
// Upsert catálogo de produtos
|
||||||
|
for (const p of products) {
|
||||||
|
await prisma.product.upsert({
|
||||||
|
where: { code: p.code },
|
||||||
|
create: { ...p, syncedAt: new Date() },
|
||||||
|
update: { name: p.name, unitPrice: p.unitPrice, stock: p.stock, syncedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`${products.length} produtos upserted.`);
|
||||||
|
|
||||||
|
// Upsert alçadas de desconto
|
||||||
|
for (const r of repDiscountLimits) {
|
||||||
|
await prisma.repDiscountLimit.upsert({
|
||||||
|
where: { repId_category: { repId: r.repId, category: r.category } },
|
||||||
|
create: r,
|
||||||
|
update: { limit: r.limit },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`${repDiscountLimits.length} alçadas configuradas.`);
|
||||||
|
|
||||||
// Upsert clients (sem lastOrderAt/openOrdersCount — calculados depois)
|
// Upsert clients (sem lastOrderAt/openOrdersCount — calculados depois)
|
||||||
for (const data of clientDefs) {
|
for (const data of clientDefs) {
|
||||||
await prisma.client.upsert({
|
await prisma.client.upsert({
|
||||||
@@ -749,10 +1016,11 @@ async function main() {
|
|||||||
for (const o of orders) {
|
for (const o of orders) {
|
||||||
const issuedAt = daysAgo(o.issuedDaysAgo);
|
const issuedAt = daysAgo(o.issuedDaysAgo);
|
||||||
|
|
||||||
// Build items with subtotals
|
// Build items with subtotals + productCategory (desnorm do catálogo)
|
||||||
const itemsData = o.items.map((it) => ({
|
const itemsData = o.items.map((it) => ({
|
||||||
productCode: it.productCode,
|
productCode: it.productCode,
|
||||||
productName: it.productName,
|
productName: it.productName,
|
||||||
|
productCategory: productCategoryMap[it.productCode] ?? 'geral',
|
||||||
quantity: it.qty,
|
quantity: it.qty,
|
||||||
unitPrice: it.unitPrice,
|
unitPrice: it.unitPrice,
|
||||||
discountPct: it.discountPct,
|
discountPct: it.discountPct,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
import { ClientsModule } from './clients/clients.module';
|
import { ClientsModule } from './clients/clients.module';
|
||||||
import { OrdersModule } from './orders/orders.module';
|
import { OrdersModule } from './orders/orders.module';
|
||||||
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -23,6 +24,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
|||||||
PingModule,
|
PingModule,
|
||||||
ClientsModule,
|
ClientsModule,
|
||||||
OrdersModule,
|
OrdersModule,
|
||||||
|
CatalogModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||||
|
|||||||
46
apps/api/src/app/catalog/catalog.controller.ts
Normal file
46
apps/api/src/app/catalog/catalog.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
ProductListQuerySchema,
|
||||||
|
ProductSyncRequestSchema,
|
||||||
|
type ProductDetail,
|
||||||
|
type ProductListQuery,
|
||||||
|
type ProductListResponse,
|
||||||
|
type ProductSyncResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { CatalogService } from './catalog.service';
|
||||||
|
|
||||||
|
class ProductListQueryDto extends createZodDto(ProductListQuerySchema) {}
|
||||||
|
class ProductSyncRequestDto extends createZodDto(ProductSyncRequestSchema) {}
|
||||||
|
|
||||||
|
@Controller({ path: 'catalog' })
|
||||||
|
export class CatalogController {
|
||||||
|
constructor(private readonly catalog: CatalogService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: ProductListQueryDto): Promise<ProductListResponse> {
|
||||||
|
const parsed = ProductListQuerySchema.parse(query) as ProductListQuery;
|
||||||
|
return this.catalog.list(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ProductDetail> {
|
||||||
|
const product = await this.catalog.findOne(id);
|
||||||
|
if (!product) throw new NotFoundException(`Produto ${id} não encontrado`);
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sync')
|
||||||
|
sync(@Body() body: ProductSyncRequestDto): Promise<ProductSyncResponse> {
|
||||||
|
return this.catalog.sync(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/app/catalog/catalog.module.ts
Normal file
10
apps/api/src/app/catalog/catalog.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CatalogController } from './catalog.controller';
|
||||||
|
import { CatalogService } from './catalog.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CatalogController],
|
||||||
|
providers: [CatalogService],
|
||||||
|
exports: [CatalogService],
|
||||||
|
})
|
||||||
|
export class CatalogModule {}
|
||||||
135
apps/api/src/app/catalog/catalog.service.ts
Normal file
135
apps/api/src/app/catalog/catalog.service.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
ProductDetail,
|
||||||
|
ProductListQuery,
|
||||||
|
ProductListResponse,
|
||||||
|
ProductSummary,
|
||||||
|
ProductSyncRequest,
|
||||||
|
ProductSyncResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
|
function decimalToString(v: Prisma.Decimal | null | undefined): string | null {
|
||||||
|
return v ? v.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogService {
|
||||||
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
async list(query: ProductListQuery): Promise<ProductListResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
|
const { q, category, page, limit } = query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: Prisma.ProductWhereInput = {
|
||||||
|
deletedAt: null,
|
||||||
|
active: true,
|
||||||
|
...(category ? { category } : {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ code: { contains: q, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
prisma.product.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
category: true,
|
||||||
|
unitPrice: true,
|
||||||
|
stock: true,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: [{ category: 'asc' }, { name: 'asc' }],
|
||||||
|
}),
|
||||||
|
prisma.product.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data: ProductSummary[] = rows.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
code: p.code,
|
||||||
|
name: p.name,
|
||||||
|
category: p.category,
|
||||||
|
unitPrice: decimalToString(p.unitPrice) ?? '0',
|
||||||
|
stock: decimalToString(p.stock),
|
||||||
|
active: p.active,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { data, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<ProductDetail | null> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
|
const p = await prisma.product.findFirst({ where: { id, deletedAt: null, active: true } });
|
||||||
|
if (!p) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
code: p.code,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
category: p.category,
|
||||||
|
unitPrice: decimalToString(p.unitPrice) ?? '0',
|
||||||
|
stock: decimalToString(p.stock),
|
||||||
|
active: p.active,
|
||||||
|
erpCode: p.erpCode,
|
||||||
|
syncedAt: p.syncedAt?.toISOString() ?? null,
|
||||||
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
updatedAt: p.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,8 +1,10 @@
|
|||||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
import { Body, Controller, Get, HttpCode, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
|
CreateOrderSchema,
|
||||||
OrderListQuerySchema,
|
OrderListQuerySchema,
|
||||||
|
type CreateOrder,
|
||||||
type OrderDetail,
|
type OrderDetail,
|
||||||
type OrderListQuery,
|
type OrderListQuery,
|
||||||
type OrderListResponse,
|
type OrderListResponse,
|
||||||
@@ -11,6 +13,7 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
|||||||
import { OrdersService } from './orders.service';
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
|
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
|
||||||
|
class CreateOrderDto extends createZodDto(CreateOrderSchema) {}
|
||||||
|
|
||||||
@Controller({ path: 'orders' })
|
@Controller({ path: 'orders' })
|
||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
@@ -25,6 +28,13 @@ export class OrdersController {
|
|||||||
return this.orders.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
return this.orders.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(201)
|
||||||
|
create(@Body() body: CreateOrderDto): Promise<OrderDetail> {
|
||||||
|
const parsed = CreateOrderSchema.parse(body) as CreateOrder;
|
||||||
|
return this.orders.create(parsed, this.cls.get('userId') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderDetail> {
|
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderDetail> {
|
||||||
return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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 { OrderStatus, Prisma } from '@prisma/client';
|
||||||
import type {
|
import type {
|
||||||
|
CreateOrder,
|
||||||
OrderDetail,
|
OrderDetail,
|
||||||
OrderListQuery,
|
OrderListQuery,
|
||||||
OrderListResponse,
|
OrderListResponse,
|
||||||
@@ -128,6 +129,217 @@ export class OrdersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cria novo pedido. Valida alçada por linha de produto (OQ-2).
|
||||||
|
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
|
||||||
|
async create(dto: CreateOrder, userId: string): Promise<OrderDetail> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
|
// Idempotency-Key: retorna pedido existente sem re-processar
|
||||||
|
if (dto.idempotencyKey) {
|
||||||
|
const existing = await prisma.order.findUnique({
|
||||||
|
where: { idempotencyKey: dto.idempotencyKey },
|
||||||
|
include: {
|
||||||
|
client: { select: { name: true } },
|
||||||
|
items: true,
|
||||||
|
history: { orderBy: { changedAt: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) return this.mapDetail(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica que o cliente existe e pertence ao rep
|
||||||
|
const client = await prisma.client.findFirst({
|
||||||
|
where: { id: dto.clientId, repId: userId, deletedAt: null },
|
||||||
|
});
|
||||||
|
if (!client) throw new NotFoundException(`Cliente ${dto.clientId} não encontrado`);
|
||||||
|
|
||||||
|
// Resolve alçadas por categoria: (repId, category) → fallback (repId, '__default__') → 5%
|
||||||
|
const limitRows = await prisma.repDiscountLimit.findMany({ where: { repId: userId } });
|
||||||
|
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
|
||||||
|
let needsApproval = false;
|
||||||
|
for (const item of dto.items) {
|
||||||
|
const lim = getLimit(item.productCategory ?? 'geral');
|
||||||
|
if (item.discountPct > lim) {
|
||||||
|
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 {
|
||||||
|
productCode: it.productCode,
|
||||||
|
productName: it.productName,
|
||||||
|
productCategory: it.productCategory ?? 'geral',
|
||||||
|
quantity: it.quantity,
|
||||||
|
unitPrice: it.unitPrice,
|
||||||
|
discountPct: it.discountPct,
|
||||||
|
subtotal,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const itemsSubtotal = itemsData.reduce((acc, it) => acc + it.subtotal, 0);
|
||||||
|
const total = Math.round(itemsSubtotal * (1 - dto.discountPct / 100) * 100) / 100;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
number,
|
||||||
|
clientId: dto.clientId,
|
||||||
|
repId: userId,
|
||||||
|
status,
|
||||||
|
discountPct: dto.discountPct,
|
||||||
|
subtotal: itemsSubtotal,
|
||||||
|
total,
|
||||||
|
notes: dto.notes ?? null,
|
||||||
|
idempotencyKey: dto.idempotencyKey ?? null,
|
||||||
|
issuedAt: now,
|
||||||
|
items: { create: itemsData },
|
||||||
|
history: {
|
||||||
|
create: [
|
||||||
|
{ fromStatus: null, toStatus: OrderStatus.budget, changedById: userId, changedAt: now },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
client: { select: { name: true } },
|
||||||
|
items: true,
|
||||||
|
history: { orderBy: { changedAt: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status === OrderStatus.pending_approval) {
|
||||||
|
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) {
|
||||||
|
// Buscar order com history atualizado
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapDetail(o: {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
clientId: string;
|
||||||
|
client: { name: string };
|
||||||
|
repId: string;
|
||||||
|
status: OrderStatus;
|
||||||
|
discountPct: Prisma.Decimal;
|
||||||
|
subtotal: Prisma.Decimal;
|
||||||
|
total: Prisma.Decimal;
|
||||||
|
notes: string | null;
|
||||||
|
approvedById: string | null;
|
||||||
|
idempotencyKey: string | null;
|
||||||
|
issuedAt: Date;
|
||||||
|
approvedAt: Date | null;
|
||||||
|
invoicedAt: Date | null;
|
||||||
|
cancelledAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
items: {
|
||||||
|
id: string;
|
||||||
|
productCode: string;
|
||||||
|
productName: string;
|
||||||
|
quantity: Prisma.Decimal;
|
||||||
|
unitPrice: Prisma.Decimal;
|
||||||
|
discountPct: Prisma.Decimal;
|
||||||
|
subtotal: Prisma.Decimal;
|
||||||
|
}[];
|
||||||
|
history: {
|
||||||
|
id: string;
|
||||||
|
fromStatus: OrderStatus | null;
|
||||||
|
toStatus: OrderStatus;
|
||||||
|
changedById: string;
|
||||||
|
note: string | null;
|
||||||
|
changedAt: Date;
|
||||||
|
}[];
|
||||||
|
}): OrderDetail {
|
||||||
|
return {
|
||||||
|
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(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Últimos N pedidos de um cliente — usado na ficha (FR-2.4).
|
// Últimos N pedidos de um cliente — usado na ficha (FR-2.4).
|
||||||
async listByClient(
|
async listByClient(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { 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, useParams } from '@tanstack/react-router';
|
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
||||||
import type { OrderSummary, OrderStatus } from '@sar/api-interface';
|
import type { OrderSummary, OrderStatus } 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';
|
||||||
@@ -77,6 +77,7 @@ const orderColumns: TableColumnsType<OrderSummary> = [
|
|||||||
|
|
||||||
export function ClientDetailPage() {
|
export function ClientDetailPage() {
|
||||||
const { id } = useParams({ from: '/clientes/$id' });
|
const { id } = useParams({ from: '/clientes/$id' });
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(id);
|
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(id);
|
||||||
const { data: orders, isLoading: ordersLoading } = useClientOrders(id);
|
const { data: orders, isLoading: ordersLoading } = useClientOrders(id);
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ export function ClientDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
<Space align="center" style={{ marginBottom: 16 }}>
|
<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.tradeName ?? client.name}
|
||||||
@@ -99,6 +100,13 @@ export function ClientDetailPage() {
|
|||||||
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
|
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
|
||||||
{ACTIVITY_LABEL[client.activityStatus]}
|
{ACTIVITY_LABEL[client.activityStatus]}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
|
||||||
|
disabled={client.financialStatus === 'blocked'}
|
||||||
|
>
|
||||||
|
Novo Pedido
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
|
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
|
||||||
|
|||||||
436
apps/web/src/cockpits/rafael/NewOrderPage.tsx
Normal file
436
apps/web/src/cockpits/rafael/NewOrderPage.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Divider,
|
||||||
|
Form,
|
||||||
|
InputNumber,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Steps,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Input,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||||
|
import type { CreateOrder, CreateOrderItem, ProductSummary } from '@sar/api-interface';
|
||||||
|
import { useClientDetail } from '../../lib/queries/clients';
|
||||||
|
import { useCatalog } from '../../lib/queries/catalog';
|
||||||
|
import { apiFetch } from '../../lib/api-client';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { Search } = Input;
|
||||||
|
|
||||||
|
type CartItem = CreateOrderItem & { key: string };
|
||||||
|
|
||||||
|
function calcItemTotal(qty: number, price: number, disc: number): number {
|
||||||
|
return Math.round(qty * price * (1 - disc / 100) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number): string {
|
||||||
|
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 1 — Selecionar Produtos ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProductStep({
|
||||||
|
cart,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
onQtyChange,
|
||||||
|
onDiscChange,
|
||||||
|
}: {
|
||||||
|
cart: CartItem[];
|
||||||
|
onAdd: (p: ProductSummary) => void;
|
||||||
|
onRemove: (key: string) => void;
|
||||||
|
onQtyChange: (key: string, qty: number) => void;
|
||||||
|
onDiscChange: (key: string, disc: number) => void;
|
||||||
|
}) {
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const { data, isLoading } = useCatalog({ q: q || undefined, limit: 20 });
|
||||||
|
|
||||||
|
const cartKeys = new Set(cart.map((c) => c.productCode));
|
||||||
|
|
||||||
|
const catalogColumns: TableColumnsType<ProductSummary> = [
|
||||||
|
{ title: 'Código', dataIndex: 'code', width: 100 },
|
||||||
|
{ title: 'Produto', dataIndex: 'name', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: 'Categoria',
|
||||||
|
dataIndex: 'category',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => <Tag>{v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Preço',
|
||||||
|
dataIndex: 'unitPrice',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: string) => fmt(Number(v)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
width: 80,
|
||||||
|
render: (_: unknown, row: ProductSummary) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
disabled={cartKeys.has(row.code)}
|
||||||
|
onClick={() => onAdd(row)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const cartColumns: TableColumnsType<CartItem> = [
|
||||||
|
{ title: 'Produto', dataIndex: 'productName', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: 'Qtd',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number, row: CartItem) => (
|
||||||
|
<InputNumber
|
||||||
|
min={0.001}
|
||||||
|
step={1}
|
||||||
|
value={v}
|
||||||
|
size="small"
|
||||||
|
style={{ width: 80 }}
|
||||||
|
onChange={(n) => onQtyChange(row.key, n ?? 1)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Desc %',
|
||||||
|
dataIndex: 'discountPct',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number, row: CartItem) => (
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.5}
|
||||||
|
value={v}
|
||||||
|
size="small"
|
||||||
|
style={{ width: 80 }}
|
||||||
|
onChange={(n) => onDiscChange(row.key, n ?? 0)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Subtotal',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
render: (_: unknown, row: CartItem) =>
|
||||||
|
fmt(calcItemTotal(row.quantity, row.unitPrice, row.discountPct)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
width: 40,
|
||||||
|
render: (_: unknown, row: CartItem) => (
|
||||||
|
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => onRemove(row.key)} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Search
|
||||||
|
placeholder="Buscar produto por nome ou código..."
|
||||||
|
allowClear
|
||||||
|
style={{ maxWidth: 400 }}
|
||||||
|
onSearch={setQ}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value) setQ('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Table<ProductSummary>
|
||||||
|
rowKey="id"
|
||||||
|
columns={catalogColumns}
|
||||||
|
dataSource={data?.data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 220 }}
|
||||||
|
/>
|
||||||
|
<Divider orientation="left">Itens do Pedido ({cart.length})</Divider>
|
||||||
|
<Table<CartItem>
|
||||||
|
rowKey="key"
|
||||||
|
columns={cartColumns}
|
||||||
|
dataSource={cart}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
locale={{ emptyText: 'Nenhum produto adicionado ainda.' }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 2 — Desconto Global + Observações ───────────────────────────────────
|
||||||
|
|
||||||
|
function ReviewStep({
|
||||||
|
cart,
|
||||||
|
globalDisc,
|
||||||
|
notes,
|
||||||
|
creditLimit,
|
||||||
|
onDiscChange,
|
||||||
|
onNotesChange,
|
||||||
|
}: {
|
||||||
|
cart: CartItem[];
|
||||||
|
globalDisc: number;
|
||||||
|
notes: string;
|
||||||
|
creditLimit: string | null;
|
||||||
|
onDiscChange: (v: number) => void;
|
||||||
|
onNotesChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const itemsSubtotal = cart.reduce(
|
||||||
|
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
|
||||||
|
const limit = creditLimit ? Number(creditLimit) : null;
|
||||||
|
const creditOk = limit === null || total <= limit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Descriptions bordered size="small" column={2}>
|
||||||
|
<Descriptions.Item label="Subtotal dos itens" span={2}>
|
||||||
|
<Text strong>{fmt(itemsSubtotal)}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Desconto global do pedido">
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.5}
|
||||||
|
value={globalDisc}
|
||||||
|
addonAfter="%"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
onChange={(n) => onDiscChange(n ?? 0)}
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Total do pedido">
|
||||||
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
|
{fmt(total)}
|
||||||
|
</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Limite de crédito">{limit ? fmt(limit) : '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Situação crédito">
|
||||||
|
<Tag color={creditOk ? 'success' : 'error'}>{creditOk ? 'OK' : 'Acima do limite'}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Form.Item label="Observações (opcional)">
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
showCount
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => onNotesChange(e.target.value)}
|
||||||
|
placeholder="Instruções de entrega, referência do comprador, etc."
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 3 — Confirmação ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ConfirmStep({
|
||||||
|
cart,
|
||||||
|
globalDisc,
|
||||||
|
notes,
|
||||||
|
clientName,
|
||||||
|
}: {
|
||||||
|
cart: CartItem[];
|
||||||
|
globalDisc: number;
|
||||||
|
notes: string;
|
||||||
|
clientName: string;
|
||||||
|
}) {
|
||||||
|
const itemsSubtotal = cart.reduce(
|
||||||
|
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Descriptions bordered size="small" column={1}>
|
||||||
|
<Descriptions.Item label="Cliente">{clientName}</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="Desconto global">{globalDisc}%</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Total">
|
||||||
|
<Text strong style={{ fontSize: 18 }}>
|
||||||
|
{fmt(total)}
|
||||||
|
</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{notes && <Descriptions.Item label="Observações">{notes}</Descriptions.Item>}
|
||||||
|
</Descriptions>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
message="O pedido será criado com status Orçamento ou Aguardando Aprovação, conforme a sua alçada de desconto."
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NewOrderPage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type SearchParams = { clientId?: string };
|
||||||
|
|
||||||
|
export function NewOrderPage() {
|
||||||
|
const { clientId } = useSearch({ strict: false }) as SearchParams;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data: client, isLoading: clientLoading } = useClientDetail(clientId);
|
||||||
|
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [cart, setCart] = useState<CartItem[]>([]);
|
||||||
|
const [globalDisc, setGlobalDisc] = useState(0);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!clientId) throw new Error('clientId ausente');
|
||||||
|
const body: CreateOrder = {
|
||||||
|
clientId,
|
||||||
|
discountPct: globalDisc,
|
||||||
|
notes: notes || undefined,
|
||||||
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
items: cart.map((it) => ({
|
||||||
|
productCode: it.productCode,
|
||||||
|
productName: it.productName,
|
||||||
|
productCategory: it.productCategory,
|
||||||
|
quantity: it.quantity,
|
||||||
|
unitPrice: it.unitPrice,
|
||||||
|
discountPct: it.discountPct,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const res = await apiFetch('/orders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as { detail?: string }).detail ?? `Erro ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: (order: { id: string }) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['clients', clientId] });
|
||||||
|
void navigate({ to: '/pedidos/$id', params: { id: order.id } });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToCart = (p: ProductSummary) => {
|
||||||
|
setCart((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
key: p.code,
|
||||||
|
productCode: p.code,
|
||||||
|
productName: p.name,
|
||||||
|
productCategory: p.category,
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: Number(p.unitPrice),
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromCart = (key: string) => setCart((prev) => prev.filter((it) => it.key !== key));
|
||||||
|
const setQty = (key: string, qty: number) =>
|
||||||
|
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, quantity: qty } : it)));
|
||||||
|
const setDisc = (key: string, disc: number) =>
|
||||||
|
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, discountPct: disc } : it)));
|
||||||
|
|
||||||
|
if (!clientId)
|
||||||
|
return <Alert type="error" message="Parâmetro clientId ausente." style={{ margin: 24 }} />;
|
||||||
|
|
||||||
|
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||||
|
|
||||||
|
if (!client)
|
||||||
|
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
|
||||||
|
|
||||||
|
const steps = [{ title: 'Produtos' }, { title: 'Desconto / Obs.' }, { title: 'Confirmar' }];
|
||||||
|
|
||||||
|
const canNext = (step === 0 && cart.length > 0) || step === 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, maxWidth: 900 }}>
|
||||||
|
<Space align="center" style={{ marginBottom: 16 }}>
|
||||||
|
<Link to="/clientes/$id" params={{ id: clientId }}>
|
||||||
|
← {client.tradeName ?? client.name}
|
||||||
|
</Link>
|
||||||
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
|
Novo Pedido
|
||||||
|
</Title>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Steps current={step} items={steps} style={{ marginBottom: 24 }} />
|
||||||
|
|
||||||
|
{step === 0 && (
|
||||||
|
<ProductStep
|
||||||
|
cart={cart}
|
||||||
|
onAdd={addToCart}
|
||||||
|
onRemove={removeFromCart}
|
||||||
|
onQtyChange={setQty}
|
||||||
|
onDiscChange={setDisc}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 1 && (
|
||||||
|
<ReviewStep
|
||||||
|
cart={cart}
|
||||||
|
globalDisc={globalDisc}
|
||||||
|
notes={notes}
|
||||||
|
creditLimit={client.creditLimit}
|
||||||
|
onDiscChange={setGlobalDisc}
|
||||||
|
onNotesChange={setNotes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<ConfirmStep
|
||||||
|
cart={cart}
|
||||||
|
globalDisc={globalDisc}
|
||||||
|
notes={notes}
|
||||||
|
clientName={client.tradeName ?? client.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={error}
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
closable
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Space>
|
||||||
|
{step > 0 && <Button onClick={() => setStep((s) => s - 1)}>Voltar</Button>}
|
||||||
|
{step < 2 && (
|
||||||
|
<Button type="primary" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
|
||||||
|
Próximo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<Button type="primary" loading={mutation.isPending} onClick={() => mutation.mutate()}>
|
||||||
|
Confirmar Pedido
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/web/src/lib/queries/catalog.ts
Normal file
26
apps/web/src/lib/queries/catalog.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ProductListResponseSchema,
|
||||||
|
type ProductListQuery,
|
||||||
|
type ProductListResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
export function useCatalog(params: Partial<ProductListQuery> = {}) {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.q) search.set('q', params.q);
|
||||||
|
if (params.category) search.set('category', params.category);
|
||||||
|
if (params.page) search.set('page', String(params.page));
|
||||||
|
if (params.limit) search.set('limit', String(params.limit));
|
||||||
|
|
||||||
|
const qs = search.toString();
|
||||||
|
return useQuery<ProductListResponse>({
|
||||||
|
queryKey: ['catalog', params],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch(`/catalog${qs ? `?${qs}` : ''}`);
|
||||||
|
if (!res.ok) throw new Error(`catalog error ${res.status}`);
|
||||||
|
return ProductListResponseSchema.parse(await res.json());
|
||||||
|
},
|
||||||
|
staleTime: 4 * 60 * 60 * 1000, // TTL 4h — FR-4.4
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { RafaelPainel } from '../cockpits/rafael/RafaelPainel';
|
|||||||
import { ClientsPage } from '../cockpits/rafael/ClientsPage';
|
import { ClientsPage } from '../cockpits/rafael/ClientsPage';
|
||||||
import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage';
|
import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage';
|
||||||
import { OrdersPage } from '../cockpits/rafael/OrdersPage';
|
import { OrdersPage } from '../cockpits/rafael/OrdersPage';
|
||||||
|
import { NewOrderPage } from '../cockpits/rafael/NewOrderPage';
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
@@ -43,6 +44,12 @@ const pedidosRoute = createRoute({
|
|||||||
component: OrdersPage,
|
component: OrdersPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const novoOrderRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/pedidos/novo',
|
||||||
|
component: NewOrderPage,
|
||||||
|
});
|
||||||
|
|
||||||
const pedidoDetailRoute = createRoute({
|
const pedidoDetailRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/pedidos/$id',
|
path: '/pedidos/$id',
|
||||||
@@ -62,6 +69,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
clientesRoute,
|
clientesRoute,
|
||||||
clienteDetailRoute,
|
clienteDetailRoute,
|
||||||
pedidosRoute,
|
pedidosRoute,
|
||||||
|
novoOrderRoute,
|
||||||
pedidoDetailRoute,
|
pedidoDetailRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './lib/ping.contract';
|
|||||||
export * from './lib/auth.contract';
|
export * from './lib/auth.contract';
|
||||||
export * from './lib/client.contract';
|
export * from './lib/client.contract';
|
||||||
export * from './lib/order.contract';
|
export * from './lib/order.contract';
|
||||||
|
export * from './lib/product.contract';
|
||||||
|
|||||||
@@ -91,3 +91,24 @@ export const OrderListResponseSchema = z.object({
|
|||||||
limit: z.number().int().positive(),
|
limit: z.number().int().positive(),
|
||||||
});
|
});
|
||||||
export type OrderListResponse = z.infer<typeof OrderListResponseSchema>;
|
export type OrderListResponse = z.infer<typeof OrderListResponseSchema>;
|
||||||
|
|
||||||
|
// ─── Create Order (POST /orders) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CreateOrderItemSchema = z.object({
|
||||||
|
productCode: z.string().min(1),
|
||||||
|
productName: z.string().min(1),
|
||||||
|
productCategory: z.string().default('geral'),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
unitPrice: z.number().positive(),
|
||||||
|
discountPct: z.number().min(0).max(100).default(0),
|
||||||
|
});
|
||||||
|
export type CreateOrderItem = z.infer<typeof CreateOrderItemSchema>;
|
||||||
|
|
||||||
|
export const CreateOrderSchema = z.object({
|
||||||
|
clientId: z.string().uuid(),
|
||||||
|
discountPct: z.number().min(0).max(100).default(0), // desconto global do pedido
|
||||||
|
notes: z.string().optional(),
|
||||||
|
idempotencyKey: z.string().optional(),
|
||||||
|
items: z.array(CreateOrderItemSchema).min(1),
|
||||||
|
});
|
||||||
|
export type CreateOrder = z.infer<typeof CreateOrderSchema>;
|
||||||
|
|||||||
71
libs/shared/api-interface/src/lib/product.contract.ts
Normal file
71
libs/shared/api-interface/src/lib/product.contract.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Contratos canônicos de C4 — Catálogo de Produtos.
|
||||||
|
// Consumidos pela API (output tipado) e pela Web (TanStack Query + parse).
|
||||||
|
|
||||||
|
// ─── Product Summary (lista) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ProductSummarySchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
code: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
category: z.string(),
|
||||||
|
unitPrice: z.string(), // Decimal serializado
|
||||||
|
stock: z.string().nullable(),
|
||||||
|
active: z.boolean(),
|
||||||
|
});
|
||||||
|
export type ProductSummary = z.infer<typeof ProductSummarySchema>;
|
||||||
|
|
||||||
|
// ─── Product Detail ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ProductDetailSchema = ProductSummarySchema.extend({
|
||||||
|
description: z.string().nullable(),
|
||||||
|
erpCode: z.string().nullable(),
|
||||||
|
syncedAt: z.iso.datetime().nullable(),
|
||||||
|
createdAt: z.iso.datetime(),
|
||||||
|
updatedAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
export type ProductDetail = z.infer<typeof ProductDetailSchema>;
|
||||||
|
|
||||||
|
// ─── List query + response ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ProductListQuerySchema = z.object({
|
||||||
|
q: z.string().optional(), // busca nome/código
|
||||||
|
category: z.string().optional(), // filtra por categoria
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
});
|
||||||
|
export type ProductListQuery = z.infer<typeof ProductListQuerySchema>;
|
||||||
|
|
||||||
|
export const ProductListResponseSchema = z.object({
|
||||||
|
data: z.array(ProductSummarySchema),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
page: z.number().int().positive(),
|
||||||
|
limit: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
export type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
|
||||||
|
|
||||||
|
// ─── 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>;
|
||||||
Reference in New Issue
Block a user