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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user