From c36451dd334100ffbe956a450a8f74369ae8721c Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 27 May 2026 23:31:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(c3):=20consulta=20de=20pedidos=20=E2=80=94?= =?UTF-8?q?=20schema,=20api,=20web=20(OrdersModule=20+=20ClientDetailPage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../20260527231226_add_order/migration.sql | 95 +++ apps/api/prisma/schema.prisma | 124 +++- apps/api/prisma/seed.ts | 654 ++++++++++++++++-- apps/api/src/app/app.module.ts | 2 + .../api/src/app/clients/clients.controller.ts | 13 + apps/api/src/app/clients/clients.module.ts | 2 + apps/api/src/app/orders/orders.controller.ts | 32 + apps/api/src/app/orders/orders.module.ts | 10 + apps/api/src/app/orders/orders.service.ts | 166 +++++ .../src/cockpits/rafael/ClientDetailPage.tsx | 155 +++++ apps/web/src/cockpits/rafael/OrdersPage.tsx | 141 ++++ apps/web/src/lib/queries/orders.ts | 56 ++ apps/web/src/lib/router.tsx | 21 +- libs/shared/api-interface/src/index.ts | 1 + .../api-interface/src/lib/order.contract.ts | 93 +++ 15 files changed, 1494 insertions(+), 71 deletions(-) create mode 100644 apps/api/prisma/migrations/20260527231226_add_order/migration.sql create mode 100644 apps/api/src/app/orders/orders.controller.ts create mode 100644 apps/api/src/app/orders/orders.module.ts create mode 100644 apps/api/src/app/orders/orders.service.ts create mode 100644 apps/web/src/cockpits/rafael/ClientDetailPage.tsx create mode 100644 apps/web/src/cockpits/rafael/OrdersPage.tsx create mode 100644 apps/web/src/lib/queries/orders.ts create mode 100644 libs/shared/api-interface/src/lib/order.contract.ts diff --git a/apps/api/prisma/migrations/20260527231226_add_order/migration.sql b/apps/api/prisma/migrations/20260527231226_add_order/migration.sql new file mode 100644 index 0000000..f08a2cd --- /dev/null +++ b/apps/api/prisma/migrations/20260527231226_add_order/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 014c248..a63b647 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -23,49 +23,137 @@ datasource db { // ─── Enums ─────────────────────────────────────────────────────────────────── // 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 { regular attention blocked } +// Status do pedido (FR-3.2). Transições: budget → pending_approval → approved → invoiced. +// Qualquer status pode ir para cancelled. +enum OrderStatus { + budget // orçamento + pending_approval // aprovação pendente + approved // aprovado + invoiced // faturado + cancelled // cancelado +} + // ─── Client (C2) ───────────────────────────────────────────────────────────── // // Cadastro sincronizado do ERP legado (FR-2.6). Rep não cria/edita no MVP. -// creditLimit: gerenciado no SAR — admin/supervisor define (OQ-4 resolvido 2026-05-27). -// lastOrderAt/lastOrderValue: desnormalizados, atualizados ao sincronizar Orders (C3/C4). -// activityStatus: calculado em runtime a partir de lastOrderAt (não persiste — evita drift). +// creditLimit: gerenciado no SAR (OQ-4 resolvido 2026-05-27). +// lastOrderAt/lastOrderValue/openOrdersCount: desnormalizados de Orders. model Client { - id String @id @default(uuid()) @db.Uuid - name String // razão social / nome completo - tradeName String? // nome fantasia - taxId String @unique // CNPJ (14 dígitos) ou CPF (11 dígitos), sem máscara - email String? - phone String? - address Json? // { street, number, complement?, district, city, state, zip } + id String @id @default(uuid()) @db.Uuid + name String + tradeName String? + taxId String @unique + email String? + phone String? + address Json? - // Situação financeira — resumo cacheável; detalhes numéricos requerem conexão financialStatus FinancialStatus @default(regular) creditLimit Decimal? @db.Decimal(15, 2) - // Desnormalizados de Orders (atualizados em C3/C4) - repId String // userId do Rep responsável (JWT sub) + repId String lastOrderAt DateTime? lastOrderValue Decimal? @db.Decimal(15, 2) openOrdersCount Int @default(0) - // Controle de sync com ERP - erpCode String? // código no ERP legado + erpCode String? syncedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - deletedAt DateTime? // soft delete — não remove fisicamente + deletedAt DateTime? + + orders Order[] @@index([repId]) @@index([taxId]) @@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]) } diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index bb4f7e3..a1bb59f 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -2,7 +2,7 @@ // Executado via: pnpm exec prisma db seed (apps/api/) // 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 pg from 'pg'; @@ -15,11 +15,25 @@ const pool = new pg.Pool({ const adapter = new PrismaPg(pool); 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_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', tradeName: 'Padaria São João', @@ -37,9 +51,6 @@ const clients = [ financialStatus: FinancialStatus.regular, creditLimit: 15000.0, repId: DEV_REP_ID, - lastOrderAt: daysAgo(10), - lastOrderValue: 2340.5, - openOrdersCount: 1, erpCode: 'CLI-001', }, { @@ -59,9 +70,6 @@ const clients = [ financialStatus: FinancialStatus.regular, creditLimit: 50000.0, repId: DEV_REP_ID, - lastOrderAt: daysAgo(5), - lastOrderValue: 12800.0, - openOrdersCount: 2, erpCode: 'CLI-002', }, { @@ -81,9 +89,6 @@ const clients = [ financialStatus: FinancialStatus.attention, creditLimit: 5000.0, repId: DEV_REP_ID, - lastOrderAt: daysAgo(35), - lastOrderValue: 890.0, - openOrdersCount: 0, erpCode: 'CLI-003', }, { @@ -103,9 +108,6 @@ const clients = [ financialStatus: FinancialStatus.regular, creditLimit: 120000.0, repId: DEV_REP_ID, - lastOrderAt: daysAgo(2), - lastOrderValue: 45600.0, - openOrdersCount: 3, erpCode: 'CLI-004', }, { @@ -125,9 +127,6 @@ const clients = [ financialStatus: FinancialStatus.blocked, creditLimit: 2000.0, repId: DEV_REP_ID, - lastOrderAt: daysAgo(75), - lastOrderValue: 340.0, - openOrdersCount: 0, erpCode: 'CLI-005', }, { @@ -147,9 +146,6 @@ const clients = [ financialStatus: FinancialStatus.regular, creditLimit: 30000.0, repId: DEV_REP_ID, - lastOrderAt: daysAgo(18), - lastOrderValue: 7200.0, - openOrdersCount: 1, erpCode: 'CLI-006', }, { @@ -169,9 +165,6 @@ const clients = [ financialStatus: FinancialStatus.regular, creditLimit: 80000.0, repId: DEV_REP_ID, - lastOrderAt: daysAgo(8), - lastOrderValue: 32100.0, - openOrdersCount: 2, erpCode: 'CLI-007', }, { @@ -191,12 +184,8 @@ const clients = [ financialStatus: FinancialStatus.attention, creditLimit: 3500.0, repId: DEV_REP_ID, - lastOrderAt: daysAgo(45), - lastOrderValue: 560.0, - openOrdersCount: 0, erpCode: 'CLI-008', }, - // Clientes do segundo rep (para testar filtro de carteira) { name: 'Empório Gourmet Curitiba Ltda', tradeName: 'Empório Gourmet', @@ -214,9 +203,6 @@ const clients = [ financialStatus: FinancialStatus.regular, creditLimit: 25000.0, repId: DEV_REP2_ID, - lastOrderAt: daysAgo(3), - lastOrderValue: 8900.0, - openOrdersCount: 1, erpCode: 'CLI-009', }, { @@ -236,43 +222,611 @@ const clients = [ financialStatus: FinancialStatus.regular, creditLimit: 8000.0, repId: DEV_REP2_ID, - lastOrderAt: daysAgo(20), - lastOrderValue: 1200.0, - openOrdersCount: 0, erpCode: 'CLI-010', }, ]; -function daysAgo(days: number): Date { - const d = new Date(); - d.setDate(d.getDate() - days); - return d; +type OrderSeed = { + num: number; + status: OrderStatus; + 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 = { + // 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() { - 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({ where: { taxId: data.taxId }, - create: { - ...data, - creditLimit: data.creditLimit, - lastOrderValue: data.lastOrderValue, - syncedAt: new Date(), - }, + create: { ...data, syncedAt: new Date() }, update: { name: data.name, financialStatus: data.financialStatus, - lastOrderAt: data.lastOrderAt, - lastOrderValue: data.lastOrderValue, - openOrdersCount: data.openOrdersCount, + creditLimit: data.creditLimit, 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() diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 3eac623..133573e 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -9,6 +9,7 @@ import { PingModule } from './ping/ping.module'; import { AuthModule } from './auth/auth.module'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { ClientsModule } from './clients/clients.module'; +import { OrdersModule } from './orders/orders.module'; import { ProblemDetailsFilter } from './filters/problem-details.filter'; @Module({ @@ -21,6 +22,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter'; HealthModule, PingModule, ClientsModule, + OrdersModule, ], providers: [ { provide: APP_PIPE, useClass: ZodValidationPipe }, diff --git a/apps/api/src/app/clients/clients.controller.ts b/apps/api/src/app/clients/clients.controller.ts index c27923b..94a131b 100644 --- a/apps/api/src/app/clients/clients.controller.ts +++ b/apps/api/src/app/clients/clients.controller.ts @@ -6,9 +6,11 @@ import { type ClientDetail, type ClientListQuery, type ClientListResponse, + type OrderSummary, } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; import { ClientsService } from './clients.service'; +import { OrdersService } from '../orders/orders.service'; class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {} @@ -16,6 +18,7 @@ class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {} export class ClientsController { constructor( private readonly clients: ClientsService, + private readonly orders: OrdersService, private readonly cls: ClsService, ) {} @@ -30,4 +33,14 @@ export class ClientsController { findOne(@Param('id', ParseUUIDPipe) id: string): Promise { 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 { + return this.orders.listByClient( + id, + this.cls.get('userId') ?? '', + this.cls.get('role') ?? 'rep', + ); + } } diff --git a/apps/api/src/app/clients/clients.module.ts b/apps/api/src/app/clients/clients.module.ts index e8b7c58..f89b8f9 100644 --- a/apps/api/src/app/clients/clients.module.ts +++ b/apps/api/src/app/clients/clients.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ClientsController } from './clients.controller'; import { ClientsService } from './clients.service'; +import { OrdersModule } from '../orders/orders.module'; @Module({ + imports: [OrdersModule], controllers: [ClientsController], providers: [ClientsService], }) diff --git a/apps/api/src/app/orders/orders.controller.ts b/apps/api/src/app/orders/orders.controller.ts new file mode 100644 index 0000000..f82d490 --- /dev/null +++ b/apps/api/src/app/orders/orders.controller.ts @@ -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, + ) {} + + @Get() + list(@Query() query: OrderListQueryDto): Promise { + 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 { + return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep'); + } +} diff --git a/apps/api/src/app/orders/orders.module.ts b/apps/api/src/app/orders/orders.module.ts new file mode 100644 index 0000000..6b429b9 --- /dev/null +++ b/apps/api/src/app/orders/orders.module.ts @@ -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 {} diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts new file mode 100644 index 0000000..c4a3333 --- /dev/null +++ b/apps/api/src/app/orders/orders.service.ts @@ -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) {} + + async list(query: OrderListQuery, userId: string, role: string): Promise { + 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 { + 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 { + 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, + })); + } +} diff --git a/apps/web/src/cockpits/rafael/ClientDetailPage.tsx b/apps/web/src/cockpits/rafael/ClientDetailPage.tsx new file mode 100644 index 0000000..479674b --- /dev/null +++ b/apps/web/src/cockpits/rafael/ClientDetailPage.tsx @@ -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 = { + regular: 'success', + attention: 'warning', + blocked: 'error', +}; +const FINANCIAL_LABEL: Record = { + regular: 'Regular', + attention: 'Atenção', + blocked: 'Bloqueado', +}; +const ACTIVITY_COLOR: Record = { + active: 'success', + alert: 'warning', + inactive: 'default', +}; +const ACTIVITY_LABEL: Record = { + active: 'Ativo', + alert: 'Alerta', + inactive: 'Inativo', +}; +const STATUS_LABEL: Record = { + budget: 'Orçamento', + pending_approval: 'Ag. Aprovação', + approved: 'Aprovado', + invoiced: 'Faturado', + cancelled: 'Cancelado', +}; +const STATUS_COLOR: Record = { + budget: 'default', + pending_approval: 'warning', + approved: 'processing', + invoiced: 'success', + cancelled: 'error', +}; + +const orderColumns: TableColumnsType = [ + { + title: 'Nº', + dataIndex: 'number', + width: 120, + render: (num: string, row: OrderSummary) => ( + + {num} + + ), + }, + { + title: 'Status', + dataIndex: 'status', + width: 140, + render: (s: OrderStatus) => {STATUS_LABEL[s]}, + }, + { + 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 ; + if (clientError || !client) + return ; + + const addr = client.address; + + return ( +
+ + ← Clientes + + {client.tradeName ?? client.name} + + + {FINANCIAL_LABEL[client.financialStatus]} + + + {ACTIVITY_LABEL[client.activityStatus]} + + + + + {client.name} + {client.taxId} + {client.email ?? '—'} + {client.phone ?? '—'} + {addr && ( + + {addr.street}, {addr.number} + {addr.complement ? `, ${addr.complement}` : ''} — {addr.district}, {addr.city}/ + {addr.state} — CEP {addr.zip} + + )} + + {client.creditLimit + ? Number(client.creditLimit).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + }) + : '—'} + + {client.openOrdersCount} + + {client.lastOrderAt ? new Date(client.lastOrderAt).toLocaleDateString('pt-BR') : '—'} + + + {client.lastOrderValue + ? Number(client.lastOrderValue).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + }) + : '—'} + + {client.erpCode && ( + {client.erpCode} + )} + + + Últimos 10 Pedidos + + + rowKey="id" + columns={orderColumns} + dataSource={orders ?? []} + loading={ordersLoading} + pagination={false} + size="small" + rowClassName={(row) => (row.status === 'pending_approval' ? 'row-pending' : '')} + /> + +
+ ); +} diff --git a/apps/web/src/cockpits/rafael/OrdersPage.tsx b/apps/web/src/cockpits/rafael/OrdersPage.tsx new file mode 100644 index 0000000..3b06023 --- /dev/null +++ b/apps/web/src/cockpits/rafael/OrdersPage.tsx @@ -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 = { + budget: 'default', + pending_approval: 'warning', + approved: 'processing', + invoiced: 'success', + cancelled: 'error', +}; + +const STATUS_LABEL: Record = { + budget: 'Orçamento', + pending_approval: 'Ag. Aprovação', + approved: 'Aprovado', + invoiced: 'Faturado', + cancelled: 'Cancelado', +}; + +const columns: TableColumnsType = [ + { + title: 'Nº', + dataIndex: 'number', + width: 120, + render: (num: string, row: OrderSummary) => ( + + {num} + + ), + }, + { + title: 'Cliente', + dataIndex: 'clientName', + ellipsis: true, + }, + { + title: 'Status', + dataIndex: 'status', + width: 150, + render: (s: OrderStatus) => ( + {STATUS_LABEL[s]}} + /> + ), + }, + { + 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(); + const [page, setPage] = useState(1); + const limit = 50; + + const { data, isLoading } = useOrderList({ + number: numberFilter || undefined, + status: statusFilter, + page, + limit, + }); + + return ( +
+ + Pedidos + + + + { + setNumberFilter(v); + setPage(1); + }} + onChange={(e) => { + if (!e.target.value) { + setNumberFilter(''); + setPage(1); + } + }} + /> +