feat(c3): consulta de pedidos — schema, api, web (OrdersModule + ClientDetailPage)
- Prisma: Order, OrderItem, OrderStatusHistory + migration - Seed: 17 pedidos em 7 clientes com itens, histórico e desnorm de clientes - @sar/api-interface: contratos Zod (OrderSummary, OrderDetail, OrderListQuery, etc.) - API: GET /orders, GET /orders/:id, GET /clients/:id/orders (últimos 10) - Web: OrdersPage (lista + filtro status/número + pending_approval highlighted) - Web: ClientDetailPage (ficha completa + últimos 10 pedidos) - Web: /pedidos e /pedidos/$id adicionados ao router; ClientDetailPage substitui placeholder Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "OrderStatus" AS ENUM ('budget', 'pending_approval', 'approved', 'invoiced', 'cancelled');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Order" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"number" TEXT NOT NULL,
|
||||||
|
"clientId" UUID NOT NULL,
|
||||||
|
"repId" TEXT NOT NULL,
|
||||||
|
"status" "OrderStatus" NOT NULL DEFAULT 'budget',
|
||||||
|
"discountPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||||
|
"subtotal" DECIMAL(15,2) NOT NULL,
|
||||||
|
"total" DECIMAL(15,2) NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"approvedById" TEXT,
|
||||||
|
"approvedAt" TIMESTAMP(3),
|
||||||
|
"invoicedAt" TIMESTAMP(3),
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"idempotencyKey" TEXT,
|
||||||
|
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"deletedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OrderItem" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"orderId" UUID NOT NULL,
|
||||||
|
"productCode" TEXT NOT NULL,
|
||||||
|
"productName" TEXT NOT NULL,
|
||||||
|
"quantity" DECIMAL(10,3) NOT NULL,
|
||||||
|
"unitPrice" DECIMAL(15,2) NOT NULL,
|
||||||
|
"discountPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||||
|
"subtotal" DECIMAL(15,2) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OrderStatusHistory" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"orderId" UUID NOT NULL,
|
||||||
|
"fromStatus" "OrderStatus",
|
||||||
|
"toStatus" "OrderStatus" NOT NULL,
|
||||||
|
"changedById" TEXT NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "OrderStatusHistory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Order_number_key" ON "Order"("number");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Order_idempotencyKey_key" ON "Order"("idempotencyKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_clientId_idx" ON "Order"("clientId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_repId_idx" ON "Order"("repId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_status_idx" ON "Order"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_issuedAt_idx" ON "Order"("issuedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_number_idx" ON "Order"("number");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_deletedAt_idx" ON "Order"("deletedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderStatusHistory_orderId_idx" ON "OrderStatusHistory"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderStatusHistory_changedAt_idx" ON "OrderStatusHistory"("changedAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Order" ADD CONSTRAINT "Order_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OrderStatusHistory" ADD CONSTRAINT "OrderStatusHistory_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -23,49 +23,137 @@ datasource db {
|
|||||||
// ─── Enums ───────────────────────────────────────────────────────────────────
|
// ─── Enums ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Situação financeira resumida do cliente — cacheável offline (FR-2.4, FR-2.5).
|
// Situação financeira resumida do cliente — cacheável offline (FR-2.4, FR-2.5).
|
||||||
// Valor numérico de crédito e inadimplência requerem conexão.
|
|
||||||
enum FinancialStatus {
|
enum FinancialStatus {
|
||||||
regular
|
regular
|
||||||
attention
|
attention
|
||||||
blocked
|
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) ─────────────────────────────────────────────────────────────
|
// ─── Client (C2) ─────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Cadastro sincronizado do ERP legado (FR-2.6). Rep não cria/edita no MVP.
|
// Cadastro sincronizado do ERP legado (FR-2.6). Rep não cria/edita no MVP.
|
||||||
// creditLimit: gerenciado no SAR — admin/supervisor define (OQ-4 resolvido 2026-05-27).
|
// creditLimit: gerenciado no SAR (OQ-4 resolvido 2026-05-27).
|
||||||
// lastOrderAt/lastOrderValue: desnormalizados, atualizados ao sincronizar Orders (C3/C4).
|
// lastOrderAt/lastOrderValue/openOrdersCount: desnormalizados de Orders.
|
||||||
// activityStatus: calculado em runtime a partir de lastOrderAt (não persiste — evita drift).
|
|
||||||
|
|
||||||
model Client {
|
model Client {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String // razão social / nome completo
|
name String
|
||||||
tradeName String? // nome fantasia
|
tradeName String?
|
||||||
taxId String @unique // CNPJ (14 dígitos) ou CPF (11 dígitos), sem máscara
|
taxId String @unique
|
||||||
email String?
|
email String?
|
||||||
phone String?
|
phone String?
|
||||||
address Json? // { street, number, complement?, district, city, state, zip }
|
address Json?
|
||||||
|
|
||||||
// Situação financeira — resumo cacheável; detalhes numéricos requerem conexão
|
|
||||||
financialStatus FinancialStatus @default(regular)
|
financialStatus FinancialStatus @default(regular)
|
||||||
creditLimit Decimal? @db.Decimal(15, 2)
|
creditLimit Decimal? @db.Decimal(15, 2)
|
||||||
|
|
||||||
// Desnormalizados de Orders (atualizados em C3/C4)
|
repId String
|
||||||
repId String // userId do Rep responsável (JWT sub)
|
|
||||||
lastOrderAt DateTime?
|
lastOrderAt DateTime?
|
||||||
lastOrderValue Decimal? @db.Decimal(15, 2)
|
lastOrderValue Decimal? @db.Decimal(15, 2)
|
||||||
openOrdersCount Int @default(0)
|
openOrdersCount Int @default(0)
|
||||||
|
|
||||||
// Controle de sync com ERP
|
erpCode String?
|
||||||
erpCode String? // código no ERP legado
|
|
||||||
syncedAt DateTime?
|
syncedAt DateTime?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime? // soft delete — não remove fisicamente
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
orders Order[]
|
||||||
|
|
||||||
@@index([repId])
|
@@index([repId])
|
||||||
@@index([taxId])
|
@@index([taxId])
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([deletedAt]) // filtragem de soft delete eficiente
|
@@index([deletedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Order (C3) ──────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Pedido emitido pelo Rep. Itens desnormalizados (produto sem FK — C4 traz catálogo).
|
||||||
|
// 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 {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
number String @unique // "PED-00001"
|
||||||
|
clientId String @db.Uuid
|
||||||
|
repId String // userId do Rep que emitiu
|
||||||
|
status OrderStatus @default(budget)
|
||||||
|
discountPct Decimal @default(0) @db.Decimal(5, 2) // % desconto global
|
||||||
|
subtotal Decimal @db.Decimal(15, 2) // soma dos itens sem desconto global
|
||||||
|
total Decimal @db.Decimal(15, 2) // subtotal × (1 - discountPct/100)
|
||||||
|
notes String?
|
||||||
|
approvedById String? // userId de quem aprovou
|
||||||
|
approvedAt DateTime?
|
||||||
|
invoicedAt DateTime?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
|
||||||
|
// Idempotency key para lançamentos offline (C4, FR-4.12)
|
||||||
|
idempotencyKey String? @unique
|
||||||
|
|
||||||
|
issuedAt DateTime @default(now()) // data de emissão pelo Rep
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
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) ──────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Item do pedido. Produto desnormalizado (nome/código como string) — catálogo virá em C4.
|
||||||
|
// discountPct: desconto por linha (além do desconto global do Order).
|
||||||
|
|
||||||
|
model OrderItem {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
orderId String @db.Uuid
|
||||||
|
productCode String // código no ERP / catálogo
|
||||||
|
productName String // desnormalizado para exibição offline
|
||||||
|
quantity Decimal @db.Decimal(10, 3)
|
||||||
|
unitPrice Decimal @db.Decimal(15, 2)
|
||||||
|
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)
|
||||||
|
|
||||||
|
@@index([orderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } from '@prisma/client';
|
import { PrismaClient, FinancialStatus, OrderStatus } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
|
|
||||||
@@ -15,11 +15,25 @@ const pool = new pg.Pool({
|
|||||||
const adapter = new PrismaPg(pool);
|
const adapter = new PrismaPg(pool);
|
||||||
const prisma = new PrismaClient({ adapter });
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
// Rep dev padrão — mesmo userId emitido pelo POST /auth/dev/token no smoke test
|
|
||||||
const DEV_REP_ID = 'user-001';
|
const DEV_REP_ID = 'user-001';
|
||||||
const DEV_REP2_ID = 'user-002';
|
const DEV_REP2_ID = 'user-002';
|
||||||
|
const APPROVER_ID = 'user-manager-01';
|
||||||
|
|
||||||
const clients = [
|
function daysAgo(days: number): Date {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - days);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcSubtotal(qty: number, price: number, discPct: number): number {
|
||||||
|
return Math.round(qty * price * (1 - discPct / 100) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderNumber(n: number): string {
|
||||||
|
return `PED-${String(n).padStart(5, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientDefs = [
|
||||||
{
|
{
|
||||||
name: 'Padaria São João Ltda',
|
name: 'Padaria São João Ltda',
|
||||||
tradeName: 'Padaria São João',
|
tradeName: 'Padaria São João',
|
||||||
@@ -37,9 +51,6 @@ const clients = [
|
|||||||
financialStatus: FinancialStatus.regular,
|
financialStatus: FinancialStatus.regular,
|
||||||
creditLimit: 15000.0,
|
creditLimit: 15000.0,
|
||||||
repId: DEV_REP_ID,
|
repId: DEV_REP_ID,
|
||||||
lastOrderAt: daysAgo(10),
|
|
||||||
lastOrderValue: 2340.5,
|
|
||||||
openOrdersCount: 1,
|
|
||||||
erpCode: 'CLI-001',
|
erpCode: 'CLI-001',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,9 +70,6 @@ const clients = [
|
|||||||
financialStatus: FinancialStatus.regular,
|
financialStatus: FinancialStatus.regular,
|
||||||
creditLimit: 50000.0,
|
creditLimit: 50000.0,
|
||||||
repId: DEV_REP_ID,
|
repId: DEV_REP_ID,
|
||||||
lastOrderAt: daysAgo(5),
|
|
||||||
lastOrderValue: 12800.0,
|
|
||||||
openOrdersCount: 2,
|
|
||||||
erpCode: 'CLI-002',
|
erpCode: 'CLI-002',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,9 +89,6 @@ const clients = [
|
|||||||
financialStatus: FinancialStatus.attention,
|
financialStatus: FinancialStatus.attention,
|
||||||
creditLimit: 5000.0,
|
creditLimit: 5000.0,
|
||||||
repId: DEV_REP_ID,
|
repId: DEV_REP_ID,
|
||||||
lastOrderAt: daysAgo(35),
|
|
||||||
lastOrderValue: 890.0,
|
|
||||||
openOrdersCount: 0,
|
|
||||||
erpCode: 'CLI-003',
|
erpCode: 'CLI-003',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -103,9 +108,6 @@ const clients = [
|
|||||||
financialStatus: FinancialStatus.regular,
|
financialStatus: FinancialStatus.regular,
|
||||||
creditLimit: 120000.0,
|
creditLimit: 120000.0,
|
||||||
repId: DEV_REP_ID,
|
repId: DEV_REP_ID,
|
||||||
lastOrderAt: daysAgo(2),
|
|
||||||
lastOrderValue: 45600.0,
|
|
||||||
openOrdersCount: 3,
|
|
||||||
erpCode: 'CLI-004',
|
erpCode: 'CLI-004',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -125,9 +127,6 @@ const clients = [
|
|||||||
financialStatus: FinancialStatus.blocked,
|
financialStatus: FinancialStatus.blocked,
|
||||||
creditLimit: 2000.0,
|
creditLimit: 2000.0,
|
||||||
repId: DEV_REP_ID,
|
repId: DEV_REP_ID,
|
||||||
lastOrderAt: daysAgo(75),
|
|
||||||
lastOrderValue: 340.0,
|
|
||||||
openOrdersCount: 0,
|
|
||||||
erpCode: 'CLI-005',
|
erpCode: 'CLI-005',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -147,9 +146,6 @@ const clients = [
|
|||||||
financialStatus: FinancialStatus.regular,
|
financialStatus: FinancialStatus.regular,
|
||||||
creditLimit: 30000.0,
|
creditLimit: 30000.0,
|
||||||
repId: DEV_REP_ID,
|
repId: DEV_REP_ID,
|
||||||
lastOrderAt: daysAgo(18),
|
|
||||||
lastOrderValue: 7200.0,
|
|
||||||
openOrdersCount: 1,
|
|
||||||
erpCode: 'CLI-006',
|
erpCode: 'CLI-006',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -169,9 +165,6 @@ const clients = [
|
|||||||
financialStatus: FinancialStatus.regular,
|
financialStatus: FinancialStatus.regular,
|
||||||
creditLimit: 80000.0,
|
creditLimit: 80000.0,
|
||||||
repId: DEV_REP_ID,
|
repId: DEV_REP_ID,
|
||||||
lastOrderAt: daysAgo(8),
|
|
||||||
lastOrderValue: 32100.0,
|
|
||||||
openOrdersCount: 2,
|
|
||||||
erpCode: 'CLI-007',
|
erpCode: 'CLI-007',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -191,12 +184,8 @@ const clients = [
|
|||||||
financialStatus: FinancialStatus.attention,
|
financialStatus: FinancialStatus.attention,
|
||||||
creditLimit: 3500.0,
|
creditLimit: 3500.0,
|
||||||
repId: DEV_REP_ID,
|
repId: DEV_REP_ID,
|
||||||
lastOrderAt: daysAgo(45),
|
|
||||||
lastOrderValue: 560.0,
|
|
||||||
openOrdersCount: 0,
|
|
||||||
erpCode: 'CLI-008',
|
erpCode: 'CLI-008',
|
||||||
},
|
},
|
||||||
// Clientes do segundo rep (para testar filtro de carteira)
|
|
||||||
{
|
{
|
||||||
name: 'Empório Gourmet Curitiba Ltda',
|
name: 'Empório Gourmet Curitiba Ltda',
|
||||||
tradeName: 'Empório Gourmet',
|
tradeName: 'Empório Gourmet',
|
||||||
@@ -214,9 +203,6 @@ const clients = [
|
|||||||
financialStatus: FinancialStatus.regular,
|
financialStatus: FinancialStatus.regular,
|
||||||
creditLimit: 25000.0,
|
creditLimit: 25000.0,
|
||||||
repId: DEV_REP2_ID,
|
repId: DEV_REP2_ID,
|
||||||
lastOrderAt: daysAgo(3),
|
|
||||||
lastOrderValue: 8900.0,
|
|
||||||
openOrdersCount: 1,
|
|
||||||
erpCode: 'CLI-009',
|
erpCode: 'CLI-009',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -236,43 +222,611 @@ const clients = [
|
|||||||
financialStatus: FinancialStatus.regular,
|
financialStatus: FinancialStatus.regular,
|
||||||
creditLimit: 8000.0,
|
creditLimit: 8000.0,
|
||||||
repId: DEV_REP2_ID,
|
repId: DEV_REP2_ID,
|
||||||
lastOrderAt: daysAgo(20),
|
|
||||||
lastOrderValue: 1200.0,
|
|
||||||
openOrdersCount: 0,
|
|
||||||
erpCode: 'CLI-010',
|
erpCode: 'CLI-010',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function daysAgo(days: number): Date {
|
type OrderSeed = {
|
||||||
const d = new Date();
|
num: number;
|
||||||
d.setDate(d.getDate() - days);
|
status: OrderStatus;
|
||||||
return d;
|
issuedDaysAgo: number;
|
||||||
|
discountPct: number;
|
||||||
|
notes?: string;
|
||||||
|
items: {
|
||||||
|
productCode: string;
|
||||||
|
productName: string;
|
||||||
|
qty: number;
|
||||||
|
unitPrice: number;
|
||||||
|
discountPct: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 17 pedidos distribuídos por 7 clientes de user-001
|
||||||
|
const ordersByTaxId: Record<string, OrderSeed[]> = {
|
||||||
|
// Padaria São João — 2 pedidos (invoiced + budget)
|
||||||
|
'12345678000195': [
|
||||||
|
{
|
||||||
|
num: 1,
|
||||||
|
status: OrderStatus.invoiced,
|
||||||
|
issuedDaysAgo: 20,
|
||||||
|
discountPct: 0,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'FAR-001',
|
||||||
|
productName: 'Farinha de Trigo 25kg',
|
||||||
|
qty: 10,
|
||||||
|
unitPrice: 89.9,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'ACU-001',
|
||||||
|
productName: 'Açúcar Cristal 50kg',
|
||||||
|
qty: 5,
|
||||||
|
unitPrice: 145.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 2,
|
||||||
|
status: OrderStatus.budget,
|
||||||
|
issuedDaysAgo: 3,
|
||||||
|
discountPct: 2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'FAR-001',
|
||||||
|
productName: 'Farinha de Trigo 25kg',
|
||||||
|
qty: 8,
|
||||||
|
unitPrice: 89.9,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'LEV-001',
|
||||||
|
productName: 'Fermento Biológico 500g',
|
||||||
|
qty: 20,
|
||||||
|
unitPrice: 12.5,
|
||||||
|
discountPct: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Supermercado Bom Preço — 3 pedidos (invoiced + approved + pending_approval)
|
||||||
|
'98765432000187': [
|
||||||
|
{
|
||||||
|
num: 3,
|
||||||
|
status: OrderStatus.invoiced,
|
||||||
|
issuedDaysAgo: 45,
|
||||||
|
discountPct: 3,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'OLE-001',
|
||||||
|
productName: 'Óleo de Soja 900ml cx18',
|
||||||
|
qty: 20,
|
||||||
|
unitPrice: 98.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'ARR-001',
|
||||||
|
productName: 'Arroz Tipo 1 5kg cx10',
|
||||||
|
qty: 15,
|
||||||
|
unitPrice: 75.0,
|
||||||
|
discountPct: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'FEI-001',
|
||||||
|
productName: 'Feijão Carioca 1kg cx10',
|
||||||
|
qty: 10,
|
||||||
|
unitPrice: 65.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 4,
|
||||||
|
status: OrderStatus.pending_approval,
|
||||||
|
issuedDaysAgo: 2,
|
||||||
|
discountPct: 5,
|
||||||
|
notes: 'Cliente solicitou desconto especial para reposição de estoque',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'OLE-001',
|
||||||
|
productName: 'Óleo de Soja 900ml cx18',
|
||||||
|
qty: 30,
|
||||||
|
unitPrice: 98.0,
|
||||||
|
discountPct: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'ARR-001',
|
||||||
|
productName: 'Arroz Tipo 1 5kg cx10',
|
||||||
|
qty: 25,
|
||||||
|
unitPrice: 75.0,
|
||||||
|
discountPct: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 5,
|
||||||
|
status: OrderStatus.approved,
|
||||||
|
issuedDaysAgo: 10,
|
||||||
|
discountPct: 0,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'SAB-001',
|
||||||
|
productName: 'Sabão em Pó 1kg cx12',
|
||||||
|
qty: 15,
|
||||||
|
unitPrice: 42.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'DET-001',
|
||||||
|
productName: 'Detergente 500ml cx24',
|
||||||
|
qty: 10,
|
||||||
|
unitPrice: 38.4,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Mercearia Zé — 1 pedido (invoiced, há 35 dias — activityStatus alert)
|
||||||
|
'11223344000156': [
|
||||||
|
{
|
||||||
|
num: 6,
|
||||||
|
status: OrderStatus.invoiced,
|
||||||
|
issuedDaysAgo: 35,
|
||||||
|
discountPct: 0,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'REF-001',
|
||||||
|
productName: 'Refrigerante 2L cx6',
|
||||||
|
qty: 5,
|
||||||
|
unitPrice: 42.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'AGU-001',
|
||||||
|
productName: 'Água Mineral 500ml cx12',
|
||||||
|
qty: 8,
|
||||||
|
unitPrice: 18.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Distribuidora Norte — 4 pedidos (2 invoiced + 1 approved + 1 pending_approval)
|
||||||
|
'55667788000143': [
|
||||||
|
{
|
||||||
|
num: 7,
|
||||||
|
status: OrderStatus.invoiced,
|
||||||
|
issuedDaysAgo: 60,
|
||||||
|
discountPct: 2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'LEI-001',
|
||||||
|
productName: 'Leite UHT Integral 1L cx12',
|
||||||
|
qty: 50,
|
||||||
|
unitPrice: 54.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'QUE-001',
|
||||||
|
productName: 'Queijo Mussarela kg',
|
||||||
|
qty: 30,
|
||||||
|
unitPrice: 38.0,
|
||||||
|
discountPct: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 8,
|
||||||
|
status: OrderStatus.invoiced,
|
||||||
|
issuedDaysAgo: 30,
|
||||||
|
discountPct: 0,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'EMB-001',
|
||||||
|
productName: 'Embutidos Sortidos kg',
|
||||||
|
qty: 40,
|
||||||
|
unitPrice: 28.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'LEI-001',
|
||||||
|
productName: 'Leite UHT Integral 1L cx12',
|
||||||
|
qty: 60,
|
||||||
|
unitPrice: 54.0,
|
||||||
|
discountPct: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 9,
|
||||||
|
status: OrderStatus.pending_approval,
|
||||||
|
issuedDaysAgo: 1,
|
||||||
|
discountPct: 8,
|
||||||
|
notes: 'Grande volume — precisa aprovação de gerente',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'LEI-001',
|
||||||
|
productName: 'Leite UHT Integral 1L cx12',
|
||||||
|
qty: 100,
|
||||||
|
unitPrice: 54.0,
|
||||||
|
discountPct: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'QUE-001',
|
||||||
|
productName: 'Queijo Mussarela kg',
|
||||||
|
qty: 80,
|
||||||
|
unitPrice: 38.0,
|
||||||
|
discountPct: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'EMB-001',
|
||||||
|
productName: 'Embutidos Sortidos kg',
|
||||||
|
qty: 60,
|
||||||
|
unitPrice: 28.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 10,
|
||||||
|
status: OrderStatus.approved,
|
||||||
|
issuedDaysAgo: 5,
|
||||||
|
discountPct: 3,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'CON-001',
|
||||||
|
productName: 'Conservas Sortidas cx24',
|
||||||
|
qty: 20,
|
||||||
|
unitPrice: 96.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'MOL-001',
|
||||||
|
productName: 'Molho de Tomate 340g cx24',
|
||||||
|
qty: 15,
|
||||||
|
unitPrice: 72.0,
|
||||||
|
discountPct: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Restaurante Sabor da Terra — 2 pedidos (invoiced + invoiced)
|
||||||
|
'77889900000132': [
|
||||||
|
{
|
||||||
|
num: 11,
|
||||||
|
status: OrderStatus.invoiced,
|
||||||
|
issuedDaysAgo: 40,
|
||||||
|
discountPct: 0,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'CAR-001',
|
||||||
|
productName: 'Carne Bovina Contrafilé kg',
|
||||||
|
qty: 20,
|
||||||
|
unitPrice: 65.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'FRA-001',
|
||||||
|
productName: 'Frango Inteiro Resfriado kg',
|
||||||
|
qty: 30,
|
||||||
|
unitPrice: 18.5,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 12,
|
||||||
|
status: OrderStatus.invoiced,
|
||||||
|
issuedDaysAgo: 15,
|
||||||
|
discountPct: 5,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'CAR-001',
|
||||||
|
productName: 'Carne Bovina Contrafilé kg',
|
||||||
|
qty: 25,
|
||||||
|
unitPrice: 65.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'PEI-001',
|
||||||
|
productName: 'Peixe Tilápia Filé kg',
|
||||||
|
qty: 15,
|
||||||
|
unitPrice: 32.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'LEG-001',
|
||||||
|
productName: 'Legumes Sortidos kg',
|
||||||
|
qty: 20,
|
||||||
|
unitPrice: 8.5,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Atacadão Central — 3 pedidos (invoiced + approved + pending_approval)
|
||||||
|
'44556677000119': [
|
||||||
|
{
|
||||||
|
num: 13,
|
||||||
|
status: OrderStatus.invoiced,
|
||||||
|
issuedDaysAgo: 50,
|
||||||
|
discountPct: 4,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'OLE-001',
|
||||||
|
productName: 'Óleo de Soja 900ml cx18',
|
||||||
|
qty: 60,
|
||||||
|
unitPrice: 98.0,
|
||||||
|
discountPct: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'ARR-001',
|
||||||
|
productName: 'Arroz Tipo 1 5kg cx10',
|
||||||
|
qty: 40,
|
||||||
|
unitPrice: 75.0,
|
||||||
|
discountPct: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'ACU-001',
|
||||||
|
productName: 'Açúcar Cristal 50kg',
|
||||||
|
qty: 15,
|
||||||
|
unitPrice: 145.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 14,
|
||||||
|
status: OrderStatus.invoiced,
|
||||||
|
issuedDaysAgo: 20,
|
||||||
|
discountPct: 3,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'FEI-001',
|
||||||
|
productName: 'Feijão Carioca 1kg cx10',
|
||||||
|
qty: 50,
|
||||||
|
unitPrice: 65.0,
|
||||||
|
discountPct: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'LEI-001',
|
||||||
|
productName: 'Leite UHT Integral 1L cx12',
|
||||||
|
qty: 40,
|
||||||
|
unitPrice: 54.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 15,
|
||||||
|
status: OrderStatus.approved,
|
||||||
|
issuedDaysAgo: 7,
|
||||||
|
discountPct: 5,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'HIG-001',
|
||||||
|
productName: 'Shampoo 400ml cx12',
|
||||||
|
qty: 30,
|
||||||
|
unitPrice: 96.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'SAB-001',
|
||||||
|
productName: 'Sabão em Pó 1kg cx12',
|
||||||
|
qty: 25,
|
||||||
|
unitPrice: 42.0,
|
||||||
|
discountPct: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Quitanda Boa Vista — 2 pedidos (cancelled + budget)
|
||||||
|
'22334455000167': [
|
||||||
|
{
|
||||||
|
num: 16,
|
||||||
|
status: OrderStatus.cancelled,
|
||||||
|
issuedDaysAgo: 50,
|
||||||
|
discountPct: 0,
|
||||||
|
notes: 'Cliente cancelou por falta de espaço em estoque',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'FRU-001',
|
||||||
|
productName: 'Frutas Sortidas cx',
|
||||||
|
qty: 10,
|
||||||
|
unitPrice: 45.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 17,
|
||||||
|
status: OrderStatus.pending_approval,
|
||||||
|
issuedDaysAgo: 1,
|
||||||
|
discountPct: 10,
|
||||||
|
notes: 'Desconto acima do limite — aguardando aprovação',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productCode: 'FRU-001',
|
||||||
|
productName: 'Frutas Sortidas cx',
|
||||||
|
qty: 8,
|
||||||
|
unitPrice: 45.0,
|
||||||
|
discountPct: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productCode: 'VER-001',
|
||||||
|
productName: 'Verduras Sortidas kg',
|
||||||
|
qty: 15,
|
||||||
|
unitPrice: 6.0,
|
||||||
|
discountPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildHistoryForStatus(status: OrderStatus, repId: string, issuedDaysAgo: number) {
|
||||||
|
const entries: {
|
||||||
|
fromStatus: OrderStatus | null;
|
||||||
|
toStatus: OrderStatus;
|
||||||
|
changedById: string;
|
||||||
|
changedAt: Date;
|
||||||
|
note?: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
fromStatus: null,
|
||||||
|
toStatus: OrderStatus.budget,
|
||||||
|
changedById: repId,
|
||||||
|
changedAt: daysAgo(issuedDaysAgo),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status === OrderStatus.budget) return entries;
|
||||||
|
|
||||||
|
if (status === OrderStatus.cancelled) {
|
||||||
|
entries.push({
|
||||||
|
fromStatus: OrderStatus.budget,
|
||||||
|
toStatus: OrderStatus.cancelled,
|
||||||
|
changedById: repId,
|
||||||
|
changedAt: daysAgo(issuedDaysAgo - 1),
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
fromStatus: OrderStatus.budget,
|
||||||
|
toStatus: OrderStatus.pending_approval,
|
||||||
|
changedById: repId,
|
||||||
|
changedAt: daysAgo(issuedDaysAgo - 0.1),
|
||||||
|
});
|
||||||
|
if (status === OrderStatus.pending_approval) return entries;
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
fromStatus: OrderStatus.pending_approval,
|
||||||
|
toStatus: OrderStatus.approved,
|
||||||
|
changedById: APPROVER_ID,
|
||||||
|
changedAt: daysAgo(issuedDaysAgo - 0.5),
|
||||||
|
});
|
||||||
|
if (status === OrderStatus.approved) return entries;
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
fromStatus: OrderStatus.approved,
|
||||||
|
toStatus: OrderStatus.invoiced,
|
||||||
|
changedById: APPROVER_ID,
|
||||||
|
changedAt: daysAgo(issuedDaysAgo - 1),
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🌱 Seed iniciado...');
|
console.log('Seed iniciado...');
|
||||||
|
|
||||||
for (const data of clients) {
|
// Upsert clients (sem lastOrderAt/openOrdersCount — calculados depois)
|
||||||
|
for (const data of clientDefs) {
|
||||||
await prisma.client.upsert({
|
await prisma.client.upsert({
|
||||||
where: { taxId: data.taxId },
|
where: { taxId: data.taxId },
|
||||||
create: {
|
create: { ...data, syncedAt: new Date() },
|
||||||
...data,
|
|
||||||
creditLimit: data.creditLimit,
|
|
||||||
lastOrderValue: data.lastOrderValue,
|
|
||||||
syncedAt: new Date(),
|
|
||||||
},
|
|
||||||
update: {
|
update: {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
financialStatus: data.financialStatus,
|
financialStatus: data.financialStatus,
|
||||||
lastOrderAt: data.lastOrderAt,
|
creditLimit: data.creditLimit,
|
||||||
lastOrderValue: data.lastOrderValue,
|
|
||||||
openOrdersCount: data.openOrdersCount,
|
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
console.log(`${clientDefs.length} clientes upserted.`);
|
||||||
|
|
||||||
console.log(`✅ ${clients.length} clientes criados/atualizados.`);
|
// Delete existing orders (re-seed idempotente)
|
||||||
|
await prisma.orderStatusHistory.deleteMany({});
|
||||||
|
await prisma.orderItem.deleteMany({});
|
||||||
|
await prisma.order.deleteMany({});
|
||||||
|
console.log('Pedidos anteriores removidos.');
|
||||||
|
|
||||||
|
let orderCount = 0;
|
||||||
|
for (const [taxId, orders] of Object.entries(ordersByTaxId)) {
|
||||||
|
const client = await prisma.client.findUniqueOrThrow({ where: { taxId } });
|
||||||
|
|
||||||
|
for (const o of orders) {
|
||||||
|
const issuedAt = daysAgo(o.issuedDaysAgo);
|
||||||
|
|
||||||
|
// Build items with subtotals
|
||||||
|
const itemsData = o.items.map((it) => ({
|
||||||
|
productCode: it.productCode,
|
||||||
|
productName: it.productName,
|
||||||
|
quantity: it.qty,
|
||||||
|
unitPrice: it.unitPrice,
|
||||||
|
discountPct: it.discountPct,
|
||||||
|
subtotal: calcSubtotal(it.qty, it.unitPrice, it.discountPct),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const itemsSubtotal = itemsData.reduce((acc, it) => acc + Number(it.subtotal), 0);
|
||||||
|
const orderTotal = Math.round(itemsSubtotal * (1 - o.discountPct / 100) * 100) / 100;
|
||||||
|
|
||||||
|
const historyEntries = buildHistoryForStatus(o.status, client.repId, o.issuedDaysAgo);
|
||||||
|
|
||||||
|
const approvedEntry = historyEntries.find((h) => h.toStatus === OrderStatus.approved);
|
||||||
|
const invoicedEntry = historyEntries.find((h) => h.toStatus === OrderStatus.invoiced);
|
||||||
|
const cancelledEntry = historyEntries.find((h) => h.toStatus === OrderStatus.cancelled);
|
||||||
|
|
||||||
|
await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
number: orderNumber(o.num),
|
||||||
|
clientId: client.id,
|
||||||
|
repId: client.repId,
|
||||||
|
status: o.status,
|
||||||
|
discountPct: o.discountPct,
|
||||||
|
subtotal: itemsSubtotal,
|
||||||
|
total: orderTotal,
|
||||||
|
notes: o.notes ?? null,
|
||||||
|
approvedById: approvedEntry ? APPROVER_ID : null,
|
||||||
|
approvedAt: approvedEntry?.changedAt ?? null,
|
||||||
|
invoicedAt: invoicedEntry?.changedAt ?? null,
|
||||||
|
cancelledAt: cancelledEntry?.changedAt ?? null,
|
||||||
|
issuedAt,
|
||||||
|
items: { create: itemsData },
|
||||||
|
history: {
|
||||||
|
create: historyEntries.map((h) => ({
|
||||||
|
fromStatus: h.fromStatus ?? null,
|
||||||
|
toStatus: h.toStatus,
|
||||||
|
changedById: h.changedById,
|
||||||
|
changedAt: h.changedAt,
|
||||||
|
note: h.note ?? null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
orderCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`${orderCount} pedidos criados.`);
|
||||||
|
|
||||||
|
// Atualiza desnorm de clientes a partir dos pedidos criados
|
||||||
|
for (const data of clientDefs) {
|
||||||
|
const client = await prisma.client.findUniqueOrThrow({ where: { taxId: data.taxId } });
|
||||||
|
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
where: { clientId: client.id, deletedAt: null, status: { not: OrderStatus.cancelled } },
|
||||||
|
orderBy: { issuedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const openStatuses: OrderStatus[] = [
|
||||||
|
OrderStatus.budget,
|
||||||
|
OrderStatus.pending_approval,
|
||||||
|
OrderStatus.approved,
|
||||||
|
];
|
||||||
|
const openCount = orders.filter((o) => openStatuses.includes(o.status)).length;
|
||||||
|
const lastOrder = orders[0];
|
||||||
|
|
||||||
|
await prisma.client.update({
|
||||||
|
where: { id: client.id },
|
||||||
|
data: {
|
||||||
|
lastOrderAt: lastOrder?.issuedAt ?? null,
|
||||||
|
lastOrderValue: lastOrder ? lastOrder.total : null,
|
||||||
|
openOrdersCount: openCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('Desnorm de clientes atualizada.');
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PingModule } from './ping/ping.module';
|
|||||||
import { AuthModule } from './auth/auth.module';
|
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 { ProblemDetailsFilter } from './filters/problem-details.filter';
|
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -21,6 +22,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
|||||||
HealthModule,
|
HealthModule,
|
||||||
PingModule,
|
PingModule,
|
||||||
ClientsModule,
|
ClientsModule,
|
||||||
|
OrdersModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
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 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) {}
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
|
|||||||
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>,
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -30,4 +33,14 @@ export class ClientsController {
|
|||||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ClientDetail> {
|
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ClientDetail> {
|
||||||
return this.clients.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
return this.clients.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ú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,8 +1,10 @@
|
|||||||
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],
|
||||||
})
|
})
|
||||||
|
|||||||
32
apps/api/src/app/orders/orders.controller.ts
Normal file
32
apps/api/src/app/orders/orders.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
OrderListQuerySchema,
|
||||||
|
type OrderDetail,
|
||||||
|
type OrderListQuery,
|
||||||
|
type OrderListResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
|
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
|
||||||
|
|
||||||
|
@Controller({ path: 'orders' })
|
||||||
|
export class OrdersController {
|
||||||
|
constructor(
|
||||||
|
private readonly orders: OrdersService,
|
||||||
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: OrderListQueryDto): Promise<OrderListResponse> {
|
||||||
|
const parsed = OrderListQuerySchema.parse(query) as OrderListQuery;
|
||||||
|
return this.orders.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderDetail> {
|
||||||
|
return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/app/orders/orders.module.ts
Normal file
10
apps/api/src/app/orders/orders.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { OrdersController } from './orders.controller';
|
||||||
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [OrdersController],
|
||||||
|
providers: [OrdersService],
|
||||||
|
exports: [OrdersService],
|
||||||
|
})
|
||||||
|
export class OrdersModule {}
|
||||||
166
apps/api/src/app/orders/orders.service.ts
Normal file
166
apps/api/src/app/orders/orders.service.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
OrderDetail,
|
||||||
|
OrderListQuery,
|
||||||
|
OrderListResponse,
|
||||||
|
OrderSummary,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
|
function decimalToString(v: Prisma.Decimal | null | undefined): string {
|
||||||
|
return v ? v.toString() : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrdersService {
|
||||||
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
async list(query: OrderListQuery, userId: string, role: string): Promise<OrderListResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
|
const { clientId, status, number, from, to, page, limit } = query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
|
||||||
|
|
||||||
|
const where: Prisma.OrderWhereInput = {
|
||||||
|
deletedAt: null,
|
||||||
|
...repFilter,
|
||||||
|
...(clientId ? { clientId } : {}),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(number ? { number: { contains: number, mode: 'insensitive' } } : {}),
|
||||||
|
...(from || to
|
||||||
|
? {
|
||||||
|
issuedAt: {
|
||||||
|
...(from ? { gte: new Date(from) } : {}),
|
||||||
|
...(to ? { lte: new Date(to) } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
include: { client: { select: { name: true } } },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { issuedAt: 'desc' },
|
||||||
|
}),
|
||||||
|
prisma.order.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data: OrderSummary[] = 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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { data, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string, userId: string, role: string): Promise<OrderDetail> {
|
||||||
|
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 o = await prisma.order.findFirst({
|
||||||
|
where: { id, deletedAt: null, ...repFilter },
|
||||||
|
include: {
|
||||||
|
client: { select: { name: true } },
|
||||||
|
items: true,
|
||||||
|
history: { orderBy: { changedAt: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!o) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||||
|
|
||||||
|
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).
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
155
apps/web/src/cockpits/rafael/ClientDetailPage.tsx
Normal file
155
apps/web/src/cockpits/rafael/ClientDetailPage.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { Link, useParams } from '@tanstack/react-router';
|
||||||
|
import type { OrderSummary, OrderStatus } from '@sar/api-interface';
|
||||||
|
import { useClientDetail } from '../../lib/queries/clients';
|
||||||
|
import { useClientOrders } from '../../lib/queries/orders';
|
||||||
|
|
||||||
|
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> = {
|
||||||
|
active: 'success',
|
||||||
|
alert: 'warning',
|
||||||
|
inactive: 'default',
|
||||||
|
};
|
||||||
|
const ACTIVITY_LABEL: Record<string, string> = {
|
||||||
|
active: 'Ativo',
|
||||||
|
alert: 'Alerta',
|
||||||
|
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> = [
|
||||||
|
{
|
||||||
|
title: 'Nº',
|
||||||
|
dataIndex: 'number',
|
||||||
|
width: 120,
|
||||||
|
render: (num: string, row: OrderSummary) => (
|
||||||
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
|
{num}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 140,
|
||||||
|
render: (s: OrderStatus) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s]}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
width: 130,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: string) =>
|
||||||
|
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Emitido em',
|
||||||
|
dataIndex: 'issuedAt',
|
||||||
|
width: 130,
|
||||||
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ClientDetailPage() {
|
||||||
|
const { id } = useParams({ from: '/clientes/$id' });
|
||||||
|
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(id);
|
||||||
|
const { data: orders, isLoading: ordersLoading } = useClientOrders(id);
|
||||||
|
|
||||||
|
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||||
|
if (clientError || !client)
|
||||||
|
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
|
||||||
|
|
||||||
|
const addr = client.address;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Space align="center" style={{ marginBottom: 16 }}>
|
||||||
|
<Link to="/clientes">← Clientes</Link>
|
||||||
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
|
{client.tradeName ?? client.name}
|
||||||
|
</Title>
|
||||||
|
<Tag color={FINANCIAL_COLOR[client.financialStatus]}>
|
||||||
|
{FINANCIAL_LABEL[client.financialStatus]}
|
||||||
|
</Tag>
|
||||||
|
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
|
||||||
|
{ACTIVITY_LABEL[client.activityStatus]}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
|
||||||
|
<Descriptions.Item label="Razão Social">{client.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="CNPJ">{client.taxId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Telefone">{client.phone ?? '—'}</Descriptions.Item>
|
||||||
|
{addr && (
|
||||||
|
<Descriptions.Item label="Endereço" span={2}>
|
||||||
|
{addr.street}, {addr.number}
|
||||||
|
{addr.complement ? `, ${addr.complement}` : ''} — {addr.district}, {addr.city}/
|
||||||
|
{addr.state} — CEP {addr.zip}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="Limite de Crédito">
|
||||||
|
{client.creditLimit
|
||||||
|
? Number(client.creditLimit).toLocaleString('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL',
|
||||||
|
})
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Pedidos em Aberto">{client.openOrdersCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Último Pedido">
|
||||||
|
{client.lastOrderAt ? new Date(client.lastOrderAt).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>
|
||||||
|
{client.erpCode && (
|
||||||
|
<Descriptions.Item label="Código ERP">{client.erpCode}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Divider orientation="left">Últimos 10 Pedidos</Divider>
|
||||||
|
|
||||||
|
<Table<OrderSummary>
|
||||||
|
rowKey="id"
|
||||||
|
columns={orderColumns}
|
||||||
|
dataSource={orders ?? []}
|
||||||
|
loading={ordersLoading}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
rowClassName={(row) => (row.status === 'pending_approval' ? 'row-pending' : '')}
|
||||||
|
/>
|
||||||
|
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
apps/web/src/cockpits/rafael/OrdersPage.tsx
Normal file
141
apps/web/src/cockpits/rafael/OrdersPage.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Table, Tag, Input, Select, Space, Typography, Badge } from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import type { OrderSummary, OrderStatus } from '@sar/api-interface';
|
||||||
|
import { useOrderList } from '../../lib/queries/orders';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
const { Search } = Input;
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<OrderStatus, string> = {
|
||||||
|
budget: 'default',
|
||||||
|
pending_approval: 'warning',
|
||||||
|
approved: 'processing',
|
||||||
|
invoiced: 'success',
|
||||||
|
cancelled: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<OrderStatus, string> = {
|
||||||
|
budget: 'Orçamento',
|
||||||
|
pending_approval: 'Ag. Aprovação',
|
||||||
|
approved: 'Aprovado',
|
||||||
|
invoiced: 'Faturado',
|
||||||
|
cancelled: 'Cancelado',
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableColumnsType<OrderSummary> = [
|
||||||
|
{
|
||||||
|
title: 'Nº',
|
||||||
|
dataIndex: 'number',
|
||||||
|
width: 120,
|
||||||
|
render: (num: string, row: OrderSummary) => (
|
||||||
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
|
{num}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
dataIndex: 'clientName',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 150,
|
||||||
|
render: (s: OrderStatus) => (
|
||||||
|
<Badge
|
||||||
|
status={STATUS_COLOR[s] as 'default' | 'warning' | 'processing' | 'success' | 'error'}
|
||||||
|
text={<Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s]}</Tag>}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
width: 130,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: string) =>
|
||||||
|
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Emitido em',
|
||||||
|
dataIndex: 'issuedAt',
|
||||||
|
width: 130,
|
||||||
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function OrdersPage() {
|
||||||
|
const [numberFilter, setNumberFilter] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
const { data, isLoading } = useOrderList({
|
||||||
|
number: numberFilter || undefined,
|
||||||
|
status: statusFilter,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Title level={3} style={{ marginBottom: 16 }}>
|
||||||
|
Pedidos
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Search
|
||||||
|
placeholder="Buscar por número..."
|
||||||
|
allowClear
|
||||||
|
style={{ width: 220 }}
|
||||||
|
onSearch={(v) => {
|
||||||
|
setNumberFilter(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value) {
|
||||||
|
setNumberFilter('');
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Status"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 160 }}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStatusFilter(v as OrderStatus | undefined);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'budget', label: 'Orçamento' },
|
||||||
|
{ value: 'pending_approval', label: 'Ag. Aprovação' },
|
||||||
|
{ value: 'approved', label: 'Aprovado' },
|
||||||
|
{ value: 'invoiced', label: 'Faturado' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelado' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table<OrderSummary>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowClassName={(row) => (row.status === 'pending_approval' ? 'row-pending' : '')}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize: limit,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: false,
|
||||||
|
onChange: (p) => setPage(p),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/web/src/lib/queries/orders.ts
Normal file
56
apps/web/src/lib/queries/orders.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
OrderListResponseSchema,
|
||||||
|
OrderDetailSchema,
|
||||||
|
type OrderListQuery,
|
||||||
|
type OrderListResponse,
|
||||||
|
type OrderDetail,
|
||||||
|
type OrderSummary,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
export function useOrderList(params: Partial<OrderListQuery> = {}) {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.clientId) search.set('clientId', params.clientId);
|
||||||
|
if (params.status) search.set('status', params.status);
|
||||||
|
if (params.number) search.set('number', params.number);
|
||||||
|
if (params.from) search.set('from', params.from);
|
||||||
|
if (params.to) search.set('to', params.to);
|
||||||
|
if (params.page) search.set('page', String(params.page));
|
||||||
|
if (params.limit) search.set('limit', String(params.limit));
|
||||||
|
|
||||||
|
const qs = search.toString();
|
||||||
|
return useQuery<OrderListResponse>({
|
||||||
|
queryKey: ['orders', params],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch(`/orders${qs ? `?${qs}` : ''}`);
|
||||||
|
if (!res.ok) throw new Error(`orders list error ${res.status}`);
|
||||||
|
return OrderListResponseSchema.parse(await res.json());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrderDetail(id: string | undefined) {
|
||||||
|
return useQuery<OrderDetail>({
|
||||||
|
queryKey: ['orders', id],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch(`/orders/${id}`);
|
||||||
|
if (!res.ok) throw new Error(`order detail error ${res.status}`);
|
||||||
|
return OrderDetailSchema.parse(await res.json());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientOrders(clientId: string | undefined) {
|
||||||
|
return useQuery<OrderSummary[]>({
|
||||||
|
queryKey: ['clients', clientId, 'orders'],
|
||||||
|
enabled: !!clientId,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch(`/clients/${clientId}/orders`);
|
||||||
|
if (!res.ok) throw new Error(`client orders error ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data as OrderSummary[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/re
|
|||||||
import { AppShell } from '../components/layout/AppShell';
|
import { AppShell } from '../components/layout/AppShell';
|
||||||
import { RafaelPainel } from '../cockpits/rafael/RafaelPainel';
|
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 { OrdersPage } from '../cockpits/rafael/OrdersPage';
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
@@ -29,15 +31,26 @@ const clientesRoute = createRoute({
|
|||||||
component: ClientsPage,
|
component: ClientsPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Placeholder detail route — ClientDetailPage virá em próxima iteração de C2
|
|
||||||
const clienteDetailRoute = createRoute({
|
const clienteDetailRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/clientes/$id',
|
path: '/clientes/$id',
|
||||||
|
component: ClientDetailPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pedidosRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/pedidos',
|
||||||
|
component: OrdersPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pedidoDetailRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/pedidos/$id',
|
||||||
component: () => {
|
component: () => {
|
||||||
const { id } = clienteDetailRoute.useParams();
|
const { id } = pedidoDetailRoute.useParams();
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
<p>Ficha do cliente {id} — em construção</p>
|
<p>Detalhe do pedido {id} — em construção</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -48,6 +61,8 @@ const routeTree = rootRoute.addChildren([
|
|||||||
rafaelRoute,
|
rafaelRoute,
|
||||||
clientesRoute,
|
clientesRoute,
|
||||||
clienteDetailRoute,
|
clienteDetailRoute,
|
||||||
|
pedidosRoute,
|
||||||
|
pedidoDetailRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './lib/ping.contract';
|
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';
|
||||||
|
|||||||
93
libs/shared/api-interface/src/lib/order.contract.ts
Normal file
93
libs/shared/api-interface/src/lib/order.contract.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Contratos canônicos de C3 — Consulta de Pedidos.
|
||||||
|
// Consumidos pela API (output tipado) e pela Web (TanStack Query + parse).
|
||||||
|
|
||||||
|
// ─── Enums ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const OrderStatusSchema = z.enum([
|
||||||
|
'budget',
|
||||||
|
'pending_approval',
|
||||||
|
'approved',
|
||||||
|
'invoiced',
|
||||||
|
'cancelled',
|
||||||
|
]);
|
||||||
|
export type OrderStatus = z.infer<typeof OrderStatusSchema>;
|
||||||
|
|
||||||
|
// ─── OrderItem ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const OrderItemSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
productCode: z.string(),
|
||||||
|
productName: z.string(),
|
||||||
|
quantity: z.string(), // Decimal serializado
|
||||||
|
unitPrice: z.string(), // Decimal serializado
|
||||||
|
discountPct: z.string(), // Decimal serializado
|
||||||
|
subtotal: z.string(), // Decimal serializado
|
||||||
|
});
|
||||||
|
export type OrderItem = z.infer<typeof OrderItemSchema>;
|
||||||
|
|
||||||
|
// ─── OrderStatusHistory ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const OrderStatusHistorySchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
fromStatus: OrderStatusSchema.nullable(),
|
||||||
|
toStatus: OrderStatusSchema,
|
||||||
|
changedById: z.string(),
|
||||||
|
note: z.string().nullable(),
|
||||||
|
changedAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
export type OrderStatusHistory = z.infer<typeof OrderStatusHistorySchema>;
|
||||||
|
|
||||||
|
// ─── Order Summary (lista) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const OrderSummarySchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
number: z.string(),
|
||||||
|
clientId: z.string().uuid(),
|
||||||
|
clientName: z.string(),
|
||||||
|
repId: z.string(),
|
||||||
|
status: OrderStatusSchema,
|
||||||
|
discountPct: z.string(),
|
||||||
|
subtotal: z.string(),
|
||||||
|
total: z.string(),
|
||||||
|
issuedAt: z.iso.datetime(),
|
||||||
|
approvedAt: z.iso.datetime().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(),
|
||||||
|
updatedAt: z.iso.datetime(),
|
||||||
|
items: z.array(OrderItemSchema),
|
||||||
|
history: z.array(OrderStatusHistorySchema),
|
||||||
|
});
|
||||||
|
export type OrderDetail = z.infer<typeof OrderDetailSchema>;
|
||||||
|
|
||||||
|
// ─── List query + response ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const OrderListQuerySchema = z.object({
|
||||||
|
clientId: z.string().uuid().optional(),
|
||||||
|
status: OrderStatusSchema.optional(),
|
||||||
|
number: z.string().optional(), // busca parcial por número
|
||||||
|
from: z.iso.datetime().optional(), // issuedAt >= from
|
||||||
|
to: z.iso.datetime().optional(), // issuedAt <= to
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
});
|
||||||
|
export type OrderListQuery = z.infer<typeof OrderListQuerySchema>;
|
||||||
|
|
||||||
|
export const OrderListResponseSchema = z.object({
|
||||||
|
data: z.array(OrderSummarySchema),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
page: z.number().int().positive(),
|
||||||
|
limit: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
export type OrderListResponse = z.infer<typeof OrderListResponseSchema>;
|
||||||
Reference in New Issue
Block a user