feat(dashboard): painel Rafael — meta, comissão, inativos, pedidos recentes (C7)

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 00:29:31 +00:00
parent 356c8e3c2c
commit 6028bf1ba9
12 changed files with 432 additions and 105 deletions

View File

@@ -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");

View File

@@ -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 // 112
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).

View File

@@ -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({

View File

@@ -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 },

View File

@@ -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<ProductSyncResponse> {
return this.catalog.sync(body);
const parsed = ProductSyncRequestSchema.parse(body) as ProductSyncRequest;
return this.catalog.sync(parsed);
}
}

View File

@@ -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<WorkspaceClsStore>,
) {}
@Get('rep')
repDashboard(): Promise<RepDashboard> {
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
}
}

View File

@@ -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 {}

View File

@@ -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<WorkspaceClsStore>) {}
async repDashboard(userId: string): Promise<RepDashboard> {
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(),
};
}
}

View File

@@ -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<string, string> = {
budget: 'Orçamento',
pending_approval: 'Ag. Aprovação',
approved: 'Aprovado',
invoiced: 'Faturado',
cancelled: 'Cancelado',
};
const STATUS_COLOR: Record<string, string> = {
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 (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 2 }} />
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Skeleton active />
</Col>
</Row>
</Flex>
);
}
const { meta, comissao, pedidosMes, pedidosRecentes, clientesInativos, syncedAt } = data;
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{/* Saudação canon (tom: Direto, Específico) */}
{/* Saudação */}
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Bom dia, Rafael
{greeting()}, Rafael
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
27 de maio · 4 visitas na agenda · 2 propostas pra avançar
{today()}
{clientesInativos.length > 0 && (
<>
{' '}
·{' '}
<span style={{ color: 'var(--orange)' }}>
{clientesInativos.length} clientes inativos
</span>
</>
)}
</Text>
</Flex>
{/* Linha 1 — Meta + KPIs rápidos */}
{/* Linha 1 — Meta + KPIs */}
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Card style={{ height: '100%' }}>
@@ -41,35 +97,37 @@ export function RafaelPainel() {
<Flex justify="space-between" align="flex-start">
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DE MAIO
META DO MÊS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
R$ {metaMes.atingido.toLocaleString('pt-BR')}
{fmt(meta.atingido)}
</Title>
<Text type="secondary">
de R${' '}
<span className="tabular-nums">
{metaMes.total.toLocaleString('pt-BR')}
</span>
de <span className="tabular-nums">{fmt(meta.total)}</span>
</Text>
</Space>
<Tag color={metaPct >= 80 ? 'success' : 'processing'}>
{metaPct}% atingido
<Tag
color={meta.pct >= 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'}
>
{meta.pct}% atingido
</Tag>
</Flex>
<Progress
percent={metaPct}
percent={Math.min(meta.pct, 100)}
showInfo={false}
strokeColor="var(--jcs-blue)"
trailColor="var(--jcs-blue-light)"
/>
<Text style={{ fontSize: 'var(--text-md)' }}>
Faltam{' '}
<strong className="tabular-nums">
R$ {falta.toLocaleString('pt-BR')}
</strong>{' '}
pra fechar maio.
</Text>
{meta.falta > 0 ? (
<Text style={{ fontSize: 'var(--text-md)' }}>
Faltam <strong className="tabular-nums">{fmt(meta.falta)}</strong> pra fechar o
mês.
</Text>
) : (
<Text style={{ fontSize: 'var(--text-md)', color: 'var(--green)' }}>
Meta batida! Comissão FLEX ativa.
</Text>
)}
</Flex>
</Card>
</Col>
@@ -81,10 +139,10 @@ export function RafaelPainel() {
PEDIDOS NO MÊS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
28
{pedidosMes}
</Title>
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
<FontAwesomeIcon icon={faArrowTrendUp} /> +18% vs abril
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
<FontAwesomeIcon icon={faArrowTrendUp} /> últimos 30 dias
</Text>
</Space>
</Card>
@@ -97,36 +155,75 @@ export function RafaelPainel() {
COMISSÃO ACUMULADA
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
R$ 2.540
{fmt(comissao.total)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
FLEX: R$ 380
</Text>
{comissao.flex > 0 && (
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
FLEX: {fmt(comissao.flex)}
</Text>
)}
</Space>
</Card>
</Col>
</Row>
{/* Linha 2 — Alertas + Próxima visita */}
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<FontAwesomeIcon
icon={faCircleExclamation}
style={{ color: 'var(--orange)' }}
/>
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
Clientes esfriando
</Space>
}
extra={<Text type="secondary">3 hoje</Text>}
extra={
clientesInativos.length > 0 ? (
<Text type="secondary">{clientesInativos.length} clientes</Text>
) : null
}
>
<Flex vertical gap={12}>
<ClienteInativoItem nome="OPENFRIOS" dias={47} ultimaCompra="R$ 3.200" />
<ClienteInativoItem nome="DISTRIBUIDORA NORTE" dias={62} ultimaCompra="R$ 1.880" />
<ClienteInativoItem nome="MERCADO SÃO PAULO" dias={71} ultimaCompra="R$ 980" />
</Flex>
{clientesInativos.length === 0 ? (
<Text type="secondary">Nenhum cliente inativo. Ótimo trabalho!</Text>
) : (
<Flex vertical gap={12}>
{clientesInativos.map((c) => (
<Flex
key={c.id}
justify="space-between"
align="center"
style={{
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 12,
background: c.diasSemCompra > 60 ? '#fff7e6' : 'var(--bg-surface-alt)',
}}
>
<Space direction="vertical" size={0}>
<Link to="/clientes/$id" params={{ id: c.id }}>
<Text strong>{c.name}</Text>
</Link>
{c.ultimaCompraValor && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Última compra:{' '}
<span className="tabular-nums">
{Number(c.ultimaCompraValor).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</span>
</Text>
)}
</Space>
<Tag
color={c.diasSemCompra > 60 ? 'orange' : 'default'}
className="tabular-nums"
>
{c.diasSemCompra >= 999 ? 'nunca comprou' : `${c.diasSemCompra}d`}
</Tag>
</Flex>
))}
</Flex>
)}
</Card>
</Col>
@@ -134,71 +231,53 @@ export function RafaelPainel() {
<Card
title={
<Space>
<FontAwesomeIcon icon={faRoute} style={{ color: 'var(--jcs-blue)' }} />
Próxima visita
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
Pedidos recentes
</Space>
}
extra={<Link to="/pedidos">Ver todos</Link>}
>
<Flex vertical gap={12}>
<Space direction="vertical" size={4}>
<Title level={4} style={{ margin: 0, color: 'var(--jcs-blue)' }}>
OPENFRIOS
</Title>
<Text type="secondary">
Rua das Indústrias, 1.245 · São Paulo, SP · 14:30
</Text>
</Space>
<Flex gap={12} wrap="wrap">
<Tag icon={<FontAwesomeIcon icon={faClipboardCheck} />} color="processing">
3 pedidos em andamento
</Tag>
<Tag icon={<FontAwesomeIcon icon={faMessage} />} color="success">
WhatsApp atualizado
</Tag>
{pedidosRecentes.length === 0 ? (
<Text type="secondary">Nenhum pedido nos últimos 7 dias.</Text>
) : (
<Flex vertical gap={10}>
{pedidosRecentes.map((o: OrderSummary) => (
<Flex key={o.id} justify="space-between" align="center">
<Space direction="vertical" size={0}>
<Link to="/pedidos/$id" params={{ id: o.id }}>
<Text strong className="tabular-nums">
{o.number}
</Text>
</Link>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{o.clientName}
</Text>
</Space>
<Flex gap={8} align="center">
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{Number(o.total).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</Text>
<Tag color={STATUS_COLOR[o.status]}>{STATUS_LABEL[o.status]}</Tag>
</Flex>
</Flex>
))}
</Flex>
</Flex>
)}
</Card>
</Col>
</Row>
{/* Footer informativo (sem ruído — tom Apple clean) */}
<Flex justify="center" style={{ paddingTop: 16 }}>
<Flex justify="space-between" style={{ paddingTop: 8 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
SAR · Força de Vendas · Powered by JCS Sistemas
</Text>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')}
</Text>
</Flex>
</Flex>
);
}
function ClienteInativoItem({
nome,
dias,
ultimaCompra,
}: {
nome: string;
dias: number;
ultimaCompra: string;
}) {
return (
<Flex
justify="space-between"
align="center"
style={{
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 12,
background: 'var(--bg-surface-alt)',
}}
>
<Space direction="vertical" size={0}>
<Text strong>{nome}</Text>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Última compra: <span className="tabular-nums">{ultimaCompra}</span>
</Text>
</Space>
<Tag color="warning" className="tabular-nums">
{dias} dias
</Tag>
</Flex>
);
}

View File

@@ -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<RepDashboard>({
queryKey: ['dashboard', 'rep'],
queryFn: async () => {
const raw = await apiFetch('/dashboard/rep');
return RepDashboardSchema.parse(raw);
},
staleTime: 5 * 60 * 1000, // 5 min
});
}

View File

@@ -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';

View File

@@ -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<typeof ClienteInativoSchema>;
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<typeof RepDashboardSchema>;