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

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