From 6028bf1ba978446dfbda9f73320822f6c6489682 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 28 May 2026 00:29:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20painel=20Rafael=20=E2=80=94?= =?UTF-8?q?=20meta,=20comiss=C3=A3o,=20inativos,=20pedidos=20recentes=20(C?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /dashboard/rep retorna meta mensal, comissão (fixa + FLEX), clientes inativos >30 dias e pedidos dos últimos 7 dias. RepTarget model com migration. RafaelPainel conectado à API real via useRepDashboard(). Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 15 + apps/api/prisma/schema.prisma | 20 ++ apps/api/prisma/seed.ts | 29 ++ apps/api/src/app/app.module.ts | 2 + .../api/src/app/catalog/catalog.controller.ts | 4 +- .../src/app/dashboard/dashboard.controller.ts | 18 ++ .../api/src/app/dashboard/dashboard.module.ts | 9 + .../src/app/dashboard/dashboard.service.ts | 109 +++++++ apps/web/src/cockpits/rafael/RafaelPainel.tsx | 287 +++++++++++------- apps/web/src/lib/queries/dashboard.ts | 14 + libs/shared/api-interface/src/index.ts | 1 + .../src/lib/dashboard.contract.ts | 29 ++ 12 files changed, 432 insertions(+), 105 deletions(-) create mode 100644 apps/api/prisma/migrations/20260528002822_c7_rep_target/migration.sql create mode 100644 apps/api/src/app/dashboard/dashboard.controller.ts create mode 100644 apps/api/src/app/dashboard/dashboard.module.ts create mode 100644 apps/api/src/app/dashboard/dashboard.service.ts create mode 100644 apps/web/src/lib/queries/dashboard.ts create mode 100644 libs/shared/api-interface/src/lib/dashboard.contract.ts diff --git a/apps/api/prisma/migrations/20260528002822_c7_rep_target/migration.sql b/apps/api/prisma/migrations/20260528002822_c7_rep_target/migration.sql new file mode 100644 index 0000000..8b4df43 --- /dev/null +++ b/apps/api/prisma/migrations/20260528002822_c7_rep_target/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "RepTarget" ( + "repId" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "month" INTEGER NOT NULL, + "targetAmount" DECIMAL(15,2) NOT NULL, + "commissionRate" DECIMAL(5,2) NOT NULL DEFAULT 3, + "flexRate" DECIMAL(5,2) NOT NULL DEFAULT 1, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RepTarget_pkey" PRIMARY KEY ("repId","year","month") +); + +-- CreateIndex +CREATE INDEX "RepTarget_repId_idx" ON "RepTarget"("repId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d78206e..f4b6900 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -171,6 +171,26 @@ model Product { @@index([deletedAt]) } +// ─── RepTarget (C7) ────────────────────────────────────────────────────────── +// +// Meta mensal e taxas de comissão por rep. Uma linha por rep/mês. +// commissionRate: % aplicada sobre o total aprovado+faturado do mês. +// flexRate: % bônus adicional quando atingido >= targetAmount. + +model RepTarget { + repId String + year Int + month Int // 1–12 + targetAmount Decimal @db.Decimal(15, 2) + commissionRate Decimal @default(3) @db.Decimal(5, 2) + flexRate Decimal @default(1) @db.Decimal(5, 2) + + updatedAt DateTime @updatedAt + + @@id([repId, year, month]) + @@index([repId]) +} + // ─── RepDiscountLimit (C4) ─────────────────────────────────────────────────── // // Alçada de desconto por rep e por linha de produto (OQ-2 resolvida 2026-05-27). diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 3cd89f1..97d6458 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -988,6 +988,35 @@ async function main() { } console.log(`${repDiscountLimits.length} alçadas configuradas.`); + // Metas mensais (C7) + const now = new Date(); + const repTargets = [ + { + repId: DEV_REP_ID, + year: now.getFullYear(), + month: now.getMonth() + 1, + targetAmount: 60000, + commissionRate: 3, + flexRate: 1, + }, + { + repId: DEV_REP2_ID, + year: now.getFullYear(), + month: now.getMonth() + 1, + targetAmount: 40000, + commissionRate: 3, + flexRate: 1, + }, + ]; + for (const t of repTargets) { + await prisma.repTarget.upsert({ + where: { repId_year_month: { repId: t.repId, year: t.year, month: t.month } }, + create: t, + update: { targetAmount: t.targetAmount }, + }); + } + console.log(`${repTargets.length} metas mensais configuradas.`); + // Upsert clients (sem lastOrderAt/openOrdersCount — calculados depois) for (const data of clientDefs) { await prisma.client.upsert({ diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 04b7d1b..8420d02 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -11,6 +11,7 @@ import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { ClientsModule } from './clients/clients.module'; import { OrdersModule } from './orders/orders.module'; import { CatalogModule } from './catalog/catalog.module'; +import { DashboardModule } from './dashboard/dashboard.module'; import { ProblemDetailsFilter } from './filters/problem-details.filter'; @Module({ @@ -25,6 +26,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter'; ClientsModule, OrdersModule, CatalogModule, + DashboardModule, ], providers: [ { provide: APP_PIPE, useClass: ZodValidationPipe }, diff --git a/apps/api/src/app/catalog/catalog.controller.ts b/apps/api/src/app/catalog/catalog.controller.ts index 2fd0271..a1179ae 100644 --- a/apps/api/src/app/catalog/catalog.controller.ts +++ b/apps/api/src/app/catalog/catalog.controller.ts @@ -15,6 +15,7 @@ import { type ProductDetail, type ProductListQuery, type ProductListResponse, + type ProductSyncRequest, type ProductSyncResponse, } from '@sar/api-interface'; import { CatalogService } from './catalog.service'; @@ -41,6 +42,7 @@ export class CatalogController { @Post('sync') sync(@Body() body: ProductSyncRequestDto): Promise { - return this.catalog.sync(body); + const parsed = ProductSyncRequestSchema.parse(body) as ProductSyncRequest; + return this.catalog.sync(parsed); } } diff --git a/apps/api/src/app/dashboard/dashboard.controller.ts b/apps/api/src/app/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..3a5d380 --- /dev/null +++ b/apps/api/src/app/dashboard/dashboard.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import type { RepDashboard } from '@sar/api-interface'; +import type { WorkspaceClsStore } from '../workspace/workspace.types'; +import { DashboardService } from './dashboard.service'; + +@Controller({ path: 'dashboard' }) +export class DashboardController { + constructor( + private readonly dashboard: DashboardService, + private readonly cls: ClsService, + ) {} + + @Get('rep') + repDashboard(): Promise { + return this.dashboard.repDashboard(this.cls.get('userId') ?? ''); + } +} diff --git a/apps/api/src/app/dashboard/dashboard.module.ts b/apps/api/src/app/dashboard/dashboard.module.ts new file mode 100644 index 0000000..c4a4a45 --- /dev/null +++ b/apps/api/src/app/dashboard/dashboard.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; + +@Module({ + controllers: [DashboardController], + providers: [DashboardService], +}) +export class DashboardModule {} diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts new file mode 100644 index 0000000..c3919d6 --- /dev/null +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -0,0 +1,109 @@ +import { Injectable } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import { OrderStatus } from '@prisma/client'; +import type { RepDashboard } from '@sar/api-interface'; +import type { WorkspaceClsStore } from '../workspace/workspace.types'; + +@Injectable() +export class DashboardService { + constructor(private readonly cls: ClsService) {} + + async repDashboard(userId: string): Promise { + const prisma = this.cls.get('prisma'); + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + + const monthStart = new Date(year, month - 1, 1); + const monthEnd = new Date(year, month, 0, 23, 59, 59, 999); + + // Meta e taxas do mês + const target = await prisma.repTarget.findUnique({ + where: { repId_year_month: { repId: userId, year, month } }, + }); + const targetAmount = target ? Number(target.targetAmount) : 0; + const commissionRate = target ? Number(target.commissionRate) : 3; + const flexRate = target ? Number(target.flexRate) : 1; + + // Pedidos aprovados/faturados do mês (base do cálculo de meta e comissão) + const approvedThisMonth = await prisma.order.findMany({ + where: { + repId: userId, + deletedAt: null, + status: { in: [OrderStatus.approved, OrderStatus.invoiced] }, + issuedAt: { gte: monthStart, lte: monthEnd }, + }, + include: { client: { select: { name: true } } }, + }); + + const atingido = approvedThisMonth.reduce((s, o) => s + Number(o.total), 0); + const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0; + const falta = Math.max(0, targetAmount - atingido); + + const fixa = Math.round(atingido * commissionRate) / 100; + const flex = + targetAmount > 0 && atingido >= targetAmount ? Math.round(atingido * flexRate) / 100 : 0; + + // Contagem total de pedidos no mês (todos status exceto cancelado) + const pedidosMes = await prisma.order.count({ + where: { + repId: userId, + deletedAt: null, + status: { not: OrderStatus.cancelled }, + issuedAt: { gte: monthStart, lte: monthEnd }, + }, + }); + + // Pedidos recentes — últimos 7 dias + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const recentOrders = await prisma.order.findMany({ + where: { + repId: userId, + deletedAt: null, + status: { not: OrderStatus.cancelled }, + issuedAt: { gte: sevenDaysAgo }, + }, + include: { client: { select: { name: true } } }, + orderBy: { issuedAt: 'desc' }, + take: 10, + }); + + // Clientes inativos — sem compra há > 30 dias (ou nunca compraram) + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const inactiveClients = await prisma.client.findMany({ + where: { + repId: userId, + deletedAt: null, + OR: [{ lastOrderAt: null }, { lastOrderAt: { lt: thirtyDaysAgo } }], + }, + orderBy: { lastOrderAt: { sort: 'asc', nulls: 'first' } }, + take: 10, + }); + + return { + meta: { atingido, total: targetAmount, pct, falta }, + comissao: { fixa, flex, total: fixa + flex }, + pedidosMes, + pedidosRecentes: recentOrders.map((o) => ({ + id: o.id, + number: o.number, + clientId: o.clientId, + clientName: o.client.name, + repId: o.repId, + status: o.status, + total: String(o.total), + discountPct: String(o.discountPct), + issuedAt: o.issuedAt.toISOString(), + })), + clientesInativos: inactiveClients.map((c) => ({ + id: c.id, + name: c.name, + diasSemCompra: c.lastOrderAt + ? Math.floor((now.getTime() - c.lastOrderAt.getTime()) / 86_400_000) + : 999, + ultimaCompraValor: c.lastOrderValue !== null ? String(c.lastOrderValue) : null, + })), + syncedAt: now.toISOString(), + }; + } +} diff --git a/apps/web/src/cockpits/rafael/RafaelPainel.tsx b/apps/web/src/cockpits/rafael/RafaelPainel.tsx index b4710cc..d5b761c 100644 --- a/apps/web/src/cockpits/rafael/RafaelPainel.tsx +++ b/apps/web/src/cockpits/rafael/RafaelPainel.tsx @@ -1,39 +1,95 @@ -import { Card, Col, Flex, Progress, Row, Space, Tag, Typography } from 'antd'; +import { Card, Col, Flex, Progress, Row, Skeleton, Space, Tag, Typography } from 'antd'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowTrendUp, - faClipboardCheck, faCircleExclamation, - faRoute, - faMessage, + faClipboardList, } from '@fortawesome/free-solid-svg-icons'; +import { Link } from '@tanstack/react-router'; +import type { OrderSummary } from '@sar/api-interface'; +import { useRepDashboard } from '../../lib/queries/dashboard'; const { Title, Text } = Typography; -/** - * Painel do Rafael (Representante) — PRIMARY persona. - * MOCK data — substituir por TanStack Query quando API estiver pronta. - * Tom canônico: Direto · Confiante · Específico (vocabulário: meta, carteira, inativo, pedido). - */ +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', +}; + +function fmt(v: number): string { + return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); +} + +function greeting(): string { + const h = new Date().getHours(); + if (h < 12) return 'Bom dia'; + if (h < 18) return 'Boa tarde'; + return 'Boa noite'; +} + +function today(): string { + return new Date().toLocaleDateString('pt-BR', { + day: 'numeric', + month: 'long', + }); +} + export function RafaelPainel() { - // Mock — em produção vem de TanStack Query - const metaMes = { atingido: 47600, total: 60000 }; - const metaPct = Math.round((metaMes.atingido / metaMes.total) * 100); - const falta = metaMes.total - metaMes.atingido; + const { data, isLoading } = useRepDashboard(); + + if (isLoading || !data) { + return ( + + + + + + + + + + + + + + + ); + } + + const { meta, comissao, pedidosMes, pedidosRecentes, clientesInativos, syncedAt } = data; return ( - {/* Saudação canon (tom: Direto, Específico) */} + {/* Saudação */} - Bom dia, Rafael + {greeting()}, Rafael - 27 de maio · 4 visitas na agenda · 2 propostas pra avançar + {today()} + {clientesInativos.length > 0 && ( + <> + {' '} + ·{' '} + + {clientesInativos.length} clientes inativos + + + )} - {/* Linha 1 — Meta + KPIs rápidos */} + {/* Linha 1 — Meta + KPIs */} @@ -41,35 +97,37 @@ export function RafaelPainel() { - META DE MAIO + META DO MÊS - R$ {metaMes.atingido.toLocaleString('pt-BR')} + {fmt(meta.atingido)} - de R${' '} - - {metaMes.total.toLocaleString('pt-BR')} - + de {fmt(meta.total)} - = 80 ? 'success' : 'processing'}> - {metaPct}% atingido + = 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'} + > + {meta.pct}% atingido - - Faltam{' '} - - R$ {falta.toLocaleString('pt-BR')} - {' '} - pra fechar maio. - + {meta.falta > 0 ? ( + + Faltam {fmt(meta.falta)} pra fechar o + mês. + + ) : ( + + Meta batida! Comissão FLEX ativa. + + )} @@ -81,10 +139,10 @@ export function RafaelPainel() { PEDIDOS NO MÊS - 28 + {pedidosMes} - - +18% vs abril + + últimos 30 dias @@ -97,36 +155,75 @@ export function RafaelPainel() { COMISSÃO ACUMULADA - R$ 2.540 + {fmt(comissao.total)} - - FLEX: R$ 380 - + {comissao.flex > 0 && ( + + FLEX: {fmt(comissao.flex)} + + )} - {/* Linha 2 — Alertas + Próxima visita */} + {/* Linha 2 — Clientes inativos + Pedidos recentes */} - + Clientes esfriando } - extra={3 hoje} + extra={ + clientesInativos.length > 0 ? ( + {clientesInativos.length} clientes + ) : null + } > - - - - - + {clientesInativos.length === 0 ? ( + Nenhum cliente inativo. Ótimo trabalho! + ) : ( + + {clientesInativos.map((c) => ( + 60 ? '#fff7e6' : 'var(--bg-surface-alt)', + }} + > + + + {c.name} + + {c.ultimaCompraValor && ( + + Última compra:{' '} + + {Number(c.ultimaCompraValor).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + })} + + + )} + + 60 ? 'orange' : 'default'} + className="tabular-nums" + > + {c.diasSemCompra >= 999 ? 'nunca comprou' : `${c.diasSemCompra}d`} + + + ))} + + )} @@ -134,71 +231,53 @@ export function RafaelPainel() { - - Próxima visita + + Pedidos recentes } + extra={Ver todos} > - - - - OPENFRIOS - - - Rua das Indústrias, 1.245 · São Paulo, SP · 14:30 - - - - } color="processing"> - 3 pedidos em andamento - - } color="success"> - WhatsApp atualizado - + {pedidosRecentes.length === 0 ? ( + Nenhum pedido nos últimos 7 dias. + ) : ( + + {pedidosRecentes.map((o: OrderSummary) => ( + + + + + {o.number} + + + + {o.clientName} + + + + + {Number(o.total).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + })} + + {STATUS_LABEL[o.status]} + + + ))} - + )} - {/* Footer informativo (sem ruído — tom Apple clean) */} - + SAR · Força de Vendas · Powered by JCS Sistemas + + Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} + ); } - -function ClienteInativoItem({ - nome, - dias, - ultimaCompra, -}: { - nome: string; - dias: number; - ultimaCompra: string; -}) { - return ( - - - {nome} - - Última compra: {ultimaCompra} - - - - {dias} dias - - - ); -} diff --git a/apps/web/src/lib/queries/dashboard.ts b/apps/web/src/lib/queries/dashboard.ts new file mode 100644 index 0000000..967b8bb --- /dev/null +++ b/apps/web/src/lib/queries/dashboard.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { RepDashboardSchema, type RepDashboard } from '@sar/api-interface'; +import { apiFetch } from '../api-client'; + +export function useRepDashboard() { + return useQuery({ + queryKey: ['dashboard', 'rep'], + queryFn: async () => { + const raw = await apiFetch('/dashboard/rep'); + return RepDashboardSchema.parse(raw); + }, + staleTime: 5 * 60 * 1000, // 5 min + }); +} diff --git a/libs/shared/api-interface/src/index.ts b/libs/shared/api-interface/src/index.ts index 1950069..9fd54e0 100644 --- a/libs/shared/api-interface/src/index.ts +++ b/libs/shared/api-interface/src/index.ts @@ -3,3 +3,4 @@ export * from './lib/auth.contract'; export * from './lib/client.contract'; export * from './lib/order.contract'; export * from './lib/product.contract'; +export * from './lib/dashboard.contract'; diff --git a/libs/shared/api-interface/src/lib/dashboard.contract.ts b/libs/shared/api-interface/src/lib/dashboard.contract.ts new file mode 100644 index 0000000..2a132bd --- /dev/null +++ b/libs/shared/api-interface/src/lib/dashboard.contract.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { OrderSummarySchema } from './order.contract'; + +export const ClienteInativoSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + diasSemCompra: z.number().int(), + ultimaCompraValor: z.string().nullable(), +}); +export type ClienteInativo = z.infer; + +export const RepDashboardSchema = z.object({ + meta: z.object({ + atingido: z.number(), + total: z.number(), + pct: z.number(), + falta: z.number(), + }), + comissao: z.object({ + fixa: z.number(), + flex: z.number(), + total: z.number(), + }), + pedidosMes: z.number().int(), + pedidosRecentes: z.array(OrderSummarySchema), + clientesInativos: z.array(ClienteInativoSchema), + syncedAt: z.iso.datetime(), +}); +export type RepDashboard = z.infer;