Dashboard de KPIs de Vendas
Painel do representante comercial em /dashboard. Benchmark: Mercos. V1 entregue
em julho/2026 (branch refactor/ui-design-system).
Arquitetura
src/app/(private)/dashboard/
├── page.tsx # Server Component: searchParams → stats → layout
├── loading.tsx # DashboardSkeleton (route-level)
├── _lib/
│ ├── types.ts # DashboardStats + shapes mínimos da API
│ ├── period.ts # resolvePeriod (período atual + anterior equivalente)
│ ├── format.ts # BRL/pct/delta pt-BR (client + server)
│ └── stats.ts # ⭐ getDashboardStats — TODA a agregação
└── _components/
├── DashboardFilters.tsx # Selects período/representada (useTransition)
├── KpiCard.tsx # stat tile com delta Badge
├── ChartCard.tsx # wrapper Card p/ gráficos e rankings
├── SalesChart.tsx # recharts: área (atual) + linha tracejada (anterior)
├── RankList.tsx # top-N leve com barra de proporção
├── PositivationCard.tsx # taxa + buckets de inatividade 30/60/90
└── DashboardSkeleton.tsx
Contrato central (leia antes de mexer)
A UI conhece apenas getDashboardStats(input): Promise<DashboardStats>
(_lib/stats.ts). A implementação atual é transitória: a API não tem endpoint
agregado, então o módulo varre GET /orders e GET /clients paginados
(server-side) e deriva tudo em memória, numa única passada.
Quando criar o endpoint agregado (ex. GET /orders/stats na nexrep-api), troque
só o corpo de stats.ts — nenhum componente muda.
Limites da implementação atual (gatilhos para o endpoint):
- ~10.000 pedidos na janela de análise (
MAX_ORDER_PAGES× 100) - ~5.000 clientes por tenant (
MAX_CLIENT_PAGES× 100) - 3 requests concorrentes (
CONCURRENT_REQUESTS), cache de 60s (revalidate) - Acima do cap:
meta.truncated= true → Badge de aviso na página
Definições de negócio
| Conceito | Regra |
|---|---|
| Faturamento | pedidos statusPrincipal === "CONCLUIDO", por issueDate. billingStatus ignorado |
| Total do pedido | Σ(quantity × netPrice) + freightValue — idêntico a OrderCard.calculateTotal (dashboard e tela de pedidos batem centavo a centavo). bonusValue fica fora |
| Ticket médio | faturamento ÷ pedidos concluídos |
| Delta % | vs período anterior equivalente; "mes" compara com mês cheio anterior; base 0 → "—" |
| Conversão | concluídos ÷ (concluídos + orçamentos) no período |
| Positivação | clientes distintos com pedido concluído no período ÷ base |
| Base da positivação | sem filtro: clientes active === 1. Com filtro de representada: carteira histórica observável (clientes com pedido dela em 120d) — não existe vínculo cliente↔representada no banco |
| Inatividade | buckets 30–60/60–90/90+ pela última compra concluída na janela de 120d; quem nunca comprou na janela cai em 90+ |
| Curva | granularity automática: ≤92 dias → dia; ≤400 → semana; acima → mês (preparado; UI atual só usa dia) |
Dataviz
Paleta validada (skill dataviz: CVD ΔE ≥ 23, contraste ≥ 3:1 sobre branco), tokens
em globals.css:
--color-chart-1#1d5fb4— série principal (passo claro do azul da marca; o#0c3973é escuro demais para marca de dado)--color-chart-2#c2703d— série de comparação, sempre tracejada + legenda (identidade nunca é só cor)
Regras herdadas da skill: gridlines sólidas hairline (nunca tracejadas), 2px de
stroke, tooltip em card branco, texto de eixo em #64748b, uma escala Y só.
Comportamentos
- Filtros via searchParams (
?period=mes&representedId=...); period inválido →mes. - Com representada filtrada, o card "Vendas por representada" (redundante) vira "Produtos que mais giram" (top por quantidade).
PlanSelectsaiu da página (era placeholder); componente segue emsrc/components/sidebar/PlanSelect.tsx.
Roadmap (decidido com o usuário)
| Fase | Entrega | Status |
|---|---|---|
| V1 | KPIs, curva, positivação/inatividade, rankings | ✅ entregue |
| V2 | Metas: entidade meta (mês × representada) na API, CRUD em Configurações, card "Realizado vs Meta" | pendente |
| V3 | Potencial recuperável (média mensal × dias parado) e curva de recompra por cliente | pendente |
| V4 | Ranking de carteira: clientes Ouro/Prata/Bronze por faturamento anual | pendente |
| V5 | Assistente comercial IA + RAG ("quem devo visitar essa semana?") | pendente |
| V6 | Motor de oportunidades (cross-sell por mix, recuperação estimada do mês) | pendente |
Fundações que a V3+ já tem neste módulo: última compra por cliente, faturamento
por cliente/período (byClient), série temporal (salesByDay). O que falta vem
do endpoint agregado (histórico > 120d sem custo de varredura).