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:
@@ -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");
|
||||||
@@ -171,6 +171,26 @@ model Product {
|
|||||||
@@index([deletedAt])
|
@@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) ───────────────────────────────────────────────────
|
// ─── RepDiscountLimit (C4) ───────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Alçada de desconto por rep e por linha de produto (OQ-2 resolvida 2026-05-27).
|
// Alçada de desconto por rep e por linha de produto (OQ-2 resolvida 2026-05-27).
|
||||||
|
|||||||
@@ -988,6 +988,35 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(`${repDiscountLimits.length} alçadas configuradas.`);
|
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)
|
// Upsert clients (sem lastOrderAt/openOrdersCount — calculados depois)
|
||||||
for (const data of clientDefs) {
|
for (const data of clientDefs) {
|
||||||
await prisma.client.upsert({
|
await prisma.client.upsert({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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 { OrdersModule } from './orders/orders.module';
|
||||||
import { CatalogModule } from './catalog/catalog.module';
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -25,6 +26,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
|||||||
ClientsModule,
|
ClientsModule,
|
||||||
OrdersModule,
|
OrdersModule,
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
|
DashboardModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type ProductDetail,
|
type ProductDetail,
|
||||||
type ProductListQuery,
|
type ProductListQuery,
|
||||||
type ProductListResponse,
|
type ProductListResponse,
|
||||||
|
type ProductSyncRequest,
|
||||||
type ProductSyncResponse,
|
type ProductSyncResponse,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { CatalogService } from './catalog.service';
|
import { CatalogService } from './catalog.service';
|
||||||
@@ -41,6 +42,7 @@ export class CatalogController {
|
|||||||
|
|
||||||
@Post('sync')
|
@Post('sync')
|
||||||
sync(@Body() body: ProductSyncRequestDto): Promise<ProductSyncResponse> {
|
sync(@Body() body: ProductSyncRequestDto): Promise<ProductSyncResponse> {
|
||||||
return this.catalog.sync(body);
|
const parsed = ProductSyncRequestSchema.parse(body) as ProductSyncRequest;
|
||||||
|
return this.catalog.sync(parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
apps/api/src/app/dashboard/dashboard.controller.ts
Normal file
18
apps/api/src/app/dashboard/dashboard.controller.ts
Normal 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') ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/dashboard/dashboard.module.ts
Normal file
9
apps/api/src/app/dashboard/dashboard.module.ts
Normal 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 {}
|
||||||
109
apps/api/src/app/dashboard/dashboard.service.ts
Normal file
109
apps/api/src/app/dashboard/dashboard.service.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faArrowTrendUp,
|
faArrowTrendUp,
|
||||||
faClipboardCheck,
|
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faRoute,
|
faClipboardList,
|
||||||
faMessage,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} 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;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
/**
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
* Painel do Rafael (Representante) — PRIMARY persona.
|
budget: 'Orçamento',
|
||||||
* MOCK data — substituir por TanStack Query quando API estiver pronta.
|
pending_approval: 'Ag. Aprovação',
|
||||||
* Tom canônico: Direto · Confiante · Específico (vocabulário: meta, carteira, inativo, pedido).
|
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() {
|
export function RafaelPainel() {
|
||||||
// Mock — em produção vem de TanStack Query
|
const { data, isLoading } = useRepDashboard();
|
||||||
const metaMes = { atingido: 47600, total: 60000 };
|
|
||||||
const metaPct = Math.round((metaMes.atingido / metaMes.total) * 100);
|
if (isLoading || !data) {
|
||||||
const falta = metaMes.total - metaMes.atingido;
|
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 (
|
return (
|
||||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
{/* Saudação canon (tom: Direto, Específico) */}
|
{/* Saudação */}
|
||||||
<Flex vertical gap={4}>
|
<Flex vertical gap={4}>
|
||||||
<Title level={2} style={{ margin: 0 }}>
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
Bom dia, Rafael
|
{greeting()}, Rafael
|
||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
<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>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Linha 1 — Meta + KPIs rápidos */}
|
{/* Linha 1 — Meta + KPIs */}
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col xs={24} md={12}>
|
<Col xs={24} md={12}>
|
||||||
<Card style={{ height: '100%' }}>
|
<Card style={{ height: '100%' }}>
|
||||||
@@ -41,35 +97,37 @@ export function RafaelPainel() {
|
|||||||
<Flex justify="space-between" align="flex-start">
|
<Flex justify="space-between" align="flex-start">
|
||||||
<Space direction="vertical" size={0}>
|
<Space direction="vertical" size={0}>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
META DE MAIO
|
META DO MÊS
|
||||||
</Text>
|
</Text>
|
||||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
R$ {metaMes.atingido.toLocaleString('pt-BR')}
|
{fmt(meta.atingido)}
|
||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
de R${' '}
|
de <span className="tabular-nums">{fmt(meta.total)}</span>
|
||||||
<span className="tabular-nums">
|
|
||||||
{metaMes.total.toLocaleString('pt-BR')}
|
|
||||||
</span>
|
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Tag color={metaPct >= 80 ? 'success' : 'processing'}>
|
<Tag
|
||||||
{metaPct}% atingido
|
color={meta.pct >= 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'}
|
||||||
|
>
|
||||||
|
{meta.pct}% atingido
|
||||||
</Tag>
|
</Tag>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Progress
|
<Progress
|
||||||
percent={metaPct}
|
percent={Math.min(meta.pct, 100)}
|
||||||
showInfo={false}
|
showInfo={false}
|
||||||
strokeColor="var(--jcs-blue)"
|
strokeColor="var(--jcs-blue)"
|
||||||
trailColor="var(--jcs-blue-light)"
|
trailColor="var(--jcs-blue-light)"
|
||||||
/>
|
/>
|
||||||
|
{meta.falta > 0 ? (
|
||||||
<Text style={{ fontSize: 'var(--text-md)' }}>
|
<Text style={{ fontSize: 'var(--text-md)' }}>
|
||||||
Faltam{' '}
|
Faltam <strong className="tabular-nums">{fmt(meta.falta)}</strong> pra fechar o
|
||||||
<strong className="tabular-nums">
|
mês.
|
||||||
R$ {falta.toLocaleString('pt-BR')}
|
|
||||||
</strong>{' '}
|
|
||||||
pra fechar maio.
|
|
||||||
</Text>
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={{ fontSize: 'var(--text-md)', color: 'var(--green)' }}>
|
||||||
|
Meta batida! Comissão FLEX ativa.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -81,10 +139,10 @@ export function RafaelPainel() {
|
|||||||
PEDIDOS NO MÊS
|
PEDIDOS NO MÊS
|
||||||
</Text>
|
</Text>
|
||||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
28
|
{pedidosMes}
|
||||||
</Title>
|
</Title>
|
||||||
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
<FontAwesomeIcon icon={faArrowTrendUp} /> +18% vs abril
|
<FontAwesomeIcon icon={faArrowTrendUp} /> últimos 30 dias
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -97,36 +155,75 @@ export function RafaelPainel() {
|
|||||||
COMISSÃO ACUMULADA
|
COMISSÃO ACUMULADA
|
||||||
</Text>
|
</Text>
|
||||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
R$ 2.540
|
{fmt(comissao.total)}
|
||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
{comissao.flex > 0 && (
|
||||||
FLEX: R$ 380
|
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
FLEX: {fmt(comissao.flex)}
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Linha 2 — Alertas + Próxima visita */}
|
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
|
||||||
icon={faCircleExclamation}
|
|
||||||
style={{ color: 'var(--orange)' }}
|
|
||||||
/>
|
|
||||||
Clientes esfriando
|
Clientes esfriando
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
extra={<Text type="secondary">3 hoje</Text>}
|
extra={
|
||||||
|
clientesInativos.length > 0 ? (
|
||||||
|
<Text type="secondary">{clientesInativos.length} clientes</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{clientesInativos.length === 0 ? (
|
||||||
|
<Text type="secondary">Nenhum cliente inativo. Ótimo trabalho!</Text>
|
||||||
|
) : (
|
||||||
<Flex vertical gap={12}>
|
<Flex vertical gap={12}>
|
||||||
<ClienteInativoItem nome="OPENFRIOS" dias={47} ultimaCompra="R$ 3.200" />
|
{clientesInativos.map((c) => (
|
||||||
<ClienteInativoItem nome="DISTRIBUIDORA NORTE" dias={62} ultimaCompra="R$ 1.880" />
|
<Flex
|
||||||
<ClienteInativoItem nome="MERCADO SÃO PAULO" dias={71} ultimaCompra="R$ 980" />
|
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>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
@@ -134,71 +231,53 @@ export function RafaelPainel() {
|
|||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<FontAwesomeIcon icon={faRoute} style={{ color: 'var(--jcs-blue)' }} />
|
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
Próxima visita
|
Pedidos recentes
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
|
extra={<Link to="/pedidos">Ver todos</Link>}
|
||||||
>
|
>
|
||||||
<Flex vertical gap={12}>
|
{pedidosRecentes.length === 0 ? (
|
||||||
<Space direction="vertical" size={4}>
|
<Text type="secondary">Nenhum pedido nos últimos 7 dias.</Text>
|
||||||
<Title level={4} style={{ margin: 0, color: 'var(--jcs-blue)' }}>
|
) : (
|
||||||
OPENFRIOS
|
<Flex vertical gap={10}>
|
||||||
</Title>
|
{pedidosRecentes.map((o: OrderSummary) => (
|
||||||
<Text type="secondary">
|
<Flex key={o.id} justify="space-between" align="center">
|
||||||
Rua das Indústrias, 1.245 · São Paulo, SP · 14:30
|
<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>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Flex gap={12} wrap="wrap">
|
<Flex gap={8} align="center">
|
||||||
<Tag icon={<FontAwesomeIcon icon={faClipboardCheck} />} color="processing">
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
3 pedidos em andamento
|
{Number(o.total).toLocaleString('pt-BR', {
|
||||||
</Tag>
|
style: 'currency',
|
||||||
<Tag icon={<FontAwesomeIcon icon={faMessage} />} color="success">
|
currency: 'BRL',
|
||||||
WhatsApp atualizado
|
})}
|
||||||
</Tag>
|
</Text>
|
||||||
|
<Tag color={STATUS_COLOR[o.status]}>{STATUS_LABEL[o.status]}</Tag>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Footer informativo (sem ruído — tom Apple clean) */}
|
<Flex justify="space-between" style={{ paddingTop: 8 }}>
|
||||||
<Flex justify="center" style={{ paddingTop: 16 }}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
SAR · Força de Vendas · Powered by JCS Sistemas
|
SAR · Força de Vendas · Powered by JCS Sistemas
|
||||||
</Text>
|
</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)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
Última compra: <span className="tabular-nums">{ultimaCompra}</span>
|
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')}
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Flex>
|
||||||
<Tag color="warning" className="tabular-nums">
|
|
||||||
{dias} dias
|
|
||||||
</Tag>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/web/src/lib/queries/dashboard.ts
Normal file
14
apps/web/src/lib/queries/dashboard.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export * from './lib/auth.contract';
|
|||||||
export * from './lib/client.contract';
|
export * from './lib/client.contract';
|
||||||
export * from './lib/order.contract';
|
export * from './lib/order.contract';
|
||||||
export * from './lib/product.contract';
|
export * from './lib/product.contract';
|
||||||
|
export * from './lib/dashboard.contract';
|
||||||
|
|||||||
29
libs/shared/api-interface/src/lib/dashboard.contract.ts
Normal file
29
libs/shared/api-interface/src/lib/dashboard.contract.ts
Normal 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>;
|
||||||
Reference in New Issue
Block a user